From ba2a390a83e1a76d22cd5c0e93bf4255d08372d7 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Fri, 21 Aug 2026 15:49:03 +0200 Subject: [PATCH] fix(agent): battery-instance fallback for the dead Win32_PowerManagementEvent provider On Windows 11 24H2/25H2 (observed on builds 26200 and 26220, session 161b838c) the MS_Power_Management_Event_Provider is still registered in the WMI repository but activating the extrinsic push query fails with WBEM_E_NOT_FOUND - the legacy provider no longer loads on exactly the OS builds new devices enroll with, so the power watcher degraded fleet-wide. Arm chain: try the push event first (still alive on older builds), then fall back to the intrinsic __InstanceModificationEvent WITHIN 30 on Win32_Battery. The WITHIN sampling runs inside WinMgmt, not in the agent; the handler path is unchanged (payload ignored, re-probe + diff), worst-case detection latency ~35s incl. debounce. collector_degraded now fires only when BOTH attempts fail; the arm log line names the active mode. Co-Authored-By: Claude Fable 5 --- docs/agent/power-state-watcher.md | 19 ++++- .../Hosts/PowerStateWatcherHost.cs | 74 +++++++++++++++---- 2 files changed, 75 insertions(+), 18 deletions(-) diff --git a/docs/agent/power-state-watcher.md b/docs/agent/power-state-watcher.md index 57534cf5..93f5934e 100644 --- a/docs/agent/power-state-watcher.md +++ b/docs/agent/power-state-watcher.md @@ -22,10 +22,21 @@ invisible. The watcher closes that gap **event-driven, with zero polling**. # Mechanism -- `PowerStateWatcherHost` (`Orchestration/Hosts`) subscribes to the WMI push event - `SELECT * FROM Win32_PowerManagementEvent WHERE EventCode = 10` (PBT_APMPOWERSTATUSCHANGE: - fires on AC↔DC switches and battery-percentage steps, granularity set by the battery - driver, typically 1–3%). The event carries no payload — each arrival re-probes via the +- `PowerStateWatcherHost` (`Orchestration/Hosts`) arms a WMI event subscription with a + two-step chain: + 1. `SELECT * FROM Win32_PowerManagementEvent WHERE EventCode = 10` (PBT_APMPOWERSTATUSCHANGE: + true kernel push, fires on AC↔DC switches and battery-percentage steps). + 2. **Fallback** `SELECT * FROM __InstanceModificationEvent WITHIN 30 WHERE TargetInstance + ISA 'Win32_Battery'` — required because on Windows 11 24H2/25H2 (observed on builds + 26200 and 26220, session 161b838c) the `MS_Power_Management_Event_Provider` is still + registered in the repository but its activation fails with `WBEM_E_NOT_FOUND`: the + legacy push provider no longer loads on exactly the OS builds new devices enroll with. + The WITHIN sampling runs inside WinMgmt (one Win32_Battery snapshot every 30 s), not in + the agent; worst-case detection latency ≈ 35 s incl. debounce. `collector_degraded` + (`watcher_arm_failed`) is emitted only when BOTH attempts fail; the agent log's arm + line names the active mode. + + Either way the arriving event's payload is ignored — each arrival re-probes via the existing `PowerStateProbe.Probe()` (cheap P/Invoke). - Snapshots are diffed by the pure `PowerStateTransitionTracker` (`Monitoring/Enrollment/SystemSignals`), which decides what to emit. The host only owns diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/PowerStateWatcherHost.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/PowerStateWatcherHost.cs index 430685c5..b517a42c 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/PowerStateWatcherHost.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/PowerStateWatcherHost.cs @@ -15,10 +15,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration /// /// Live AC/battery watcher: subscribes to the WMI push event /// Win32_PowerManagementEvent WHERE EventCode = 10 (PBT_APMPOWERSTATUSCHANGE — fires on - /// AC↔DC switches and battery-percentage steps; the event carries no payload, so each arrival - /// re-probes via ) and diffs the snapshots through the pure - /// into power_state_change events. No polling: - /// between pushes the host is completely idle. Complements the one-shot startup + /// AC↔DC switches and battery-percentage steps), falling back to the intrinsic + /// __InstanceModificationEvent WITHIN 30 … Win32_Battery where the legacy push provider + /// no longer loads (Windows 11 24H2/25H2, builds 26200+: activation fails WBEM_E_NOT_FOUND + /// although the provider is still registered — session 161b838c). Either way the arriving + /// event's payload is ignored; each arrival re-probes via and + /// diffs the snapshots through the pure into + /// power_state_change events. No polling in this process (the fallback's WITHIN + /// sampling runs inside WinMgmt). Complements the one-shot startup /// power_state_check (StartupEnvironmentProbes), which stays the baseline event. /// /// Devices without a battery (or with a failing probe) never arm the WMI subscription — zero @@ -117,10 +121,50 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration if (!armWmi) return; + // Arm chain: the extrinsic push event first (zero WinMgmt cost, still alive on older + // builds), then the intrinsic Win32_Battery modification event. On Windows 11 24H2/25H2 + // (observed on builds 26200/26220, session 161b838c) the MS_Power_Management_Event_Provider + // is still REGISTERED but its activation fails with WBEM_E_NOT_FOUND — the legacy provider + // no longer loads, so the push query throws on exactly the devices that enroll today. + // The fallback's WITHIN polling runs inside WinMgmt (a Win32_Battery snapshot every 30 s), + // NOT in this process; the handler path is identical (payload ignored, re-probe + diff), + // worst-case detection latency ≈ 35 s incl. debounce. + if (TryArmWatcher( + "SELECT * FROM Win32_PowerManagementEvent WHERE EventCode = 10", + "power_management_event (push)", baseline, out var pushError)) + { + return; + } + + if (TryArmWatcher( + "SELECT * FROM __InstanceModificationEvent WITHIN 30 WHERE TargetInstance ISA 'Win32_Battery'", + "battery_instance_poll (WinMgmt WITHIN 30s)", baseline, out var fallbackError)) + { + return; + } + + _logger.Warning($"[{Name}] could not arm any WMI power watcher — " + + $"push: {Describe(pushError)}; fallback: {Describe(fallbackError)} — " + + "live power-state transitions will not be observed this run"); + CollectorDegradationReporter.Report(_post, _sessionId, _tenantId, Name, "watcher_arm_failed", + fallbackError ?? pushError); + } + + private static string Describe(Exception? ex) + => ex == null ? "n/a" : $"{ex.GetType().Name}: {ex.Message}"; + + /// + /// One arm attempt for the given WQL event query, with the full dispose-race dance + /// (ConsoleBypassWatcher pattern). Returns true when the chain should stop: the watcher + /// armed, or disposal won the race (then arming anything would leak a subscription). + /// Returns false with the captured exception so the caller can try the next query. + /// + private bool TryArmWatcher(string wql, string modeLabel, PowerStateResult baseline, out Exception? error) + { + error = null; try { - var query = new WqlEventQuery("SELECT * FROM Win32_PowerManagementEvent WHERE EventCode = 10"); - var watcher = new ManagementEventWatcher(query); + var watcher = new ManagementEventWatcher(new WqlEventQuery(wql)); watcher.EventArrived += OnPowerStatusChange; lock (_lock) @@ -130,7 +174,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration // Dispose won the race against the background start — don't arm. try { watcher.EventArrived -= OnPowerStatusChange; } catch { } try { watcher.Dispose(); } catch { } - return; + return true; } _watcher = watcher; } @@ -151,25 +195,27 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration try { watcher.EventArrived -= OnPowerStatusChange; } catch { } try { watcher.Stop(); } catch { } try { watcher.Dispose(); } catch { } - return; + return true; } - _logger.Info($"[{Name}] watching Win32_PowerManagementEvent (EventCode 10) — " + + _logger.Info($"[{Name}] armed via {modeLabel} — " + $"baseline: {(baseline.OnAcPower ? "AC" : "battery")}, {baseline.BatteryPercent?.ToString() ?? "?"}%"); + return true; } catch (Exception ex) { - _logger.Warning($"[{Name}] could not arm WMI power watcher ({ex.GetType().Name}: {ex.Message}) — " + - "live power-state transitions will not be observed this run"); + error = ex; + _logger.Info($"[{Name}] arm attempt '{modeLabel}' failed ({ex.GetType().Name}: {ex.Message})"); lock (_lock) { _watcher = null; } - CollectorDegradationReporter.Report(_post, _sessionId, _tenantId, Name, "watcher_arm_failed", ex); + return false; } } private void OnPowerStatusChange(object sender, EventArrivedEventArgs e) { - // The WMI event carries no usable payload; it only tells us "power status changed". - // Debounce, then re-probe — never call watcher.Stop() from inside this callback. + // Push and fallback events are treated identically: the payload is ignored, the event + // only tells us "power status may have changed". Debounce, then re-probe — never call + // watcher.Stop() from inside this callback. try { ScheduleDebouncedTick(); } catch (Exception ex) { _logger.Debug($"[{Name}] power event handler error: {ex.Message}"); } }