From 8f248766823b57d623eeb4fda2825b8fde4ec49c Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Fri, 21 Aug 2026 15:24:41 +0200 Subject: [PATCH] feat(agent): live power-state watcher + battery analyze rules DEV-009/010 Event-driven AC/battery observation during enrollment (no polling): a WMI Win32_PowerManagementEvent (EventCode 10) push subscription re-probes GetSystemPowerStatus and diffs snapshots through a pure tracker into power_state_change events - transition ac_to_battery (Warning, immediate) / battery_to_ac (Info) / threshold_crossed at 50/30/15% (per-level lifetime latch, lowest-crossed-only on jumps, arm-time baseline covers sessions starting low, 5s trailing-edge debounce, emission cap 20). Never armed on devices without a battery; arm failure surfaces as collector_degraded. Kill-switch Collectors.EnablePowerStateWatcher (ConfigVersion 38). power_state_change is classified non-activity on both sides (SignalActivityClassifier + IsPeriodicOrStallEvent) so battery drain can neither keep idle collectors alive nor heal a Stalled session. New analyze rules, both with on_event:power_state_change interim triggers and deliberately single-required-condition (event_data conditions scan events independently - no same-instance join): - ANALYZE-DEV-009 (high): thresholdPercent equals 15 - ANALYZE-DEV-010 (warning): transition equals ac_to_battery Co-Authored-By: Claude Fable 5 --- docs/agent/index.md | 1 + docs/agent/power-state-watcher.md | 95 ++++++ docs/index.md | 1 + docs/log.md | 2 + docs/rules/analyze-rule-triggers.md | 2 +- rules/analyze/ANALYZE-DEV-009.json | 48 +++ rules/analyze/ANALYZE-DEV-010.json | 48 +++ rules/dist/analyze-rules.json | 94 ++++++ .../PowerStateTransitionTrackerTests.cs | 258 ++++++++++++++++ .../PowerStateWatcherHostTests.cs | 162 ++++++++++ .../SignalActivityClassifierTests.cs | 2 + .../PowerStateTransitionTracker.cs | 197 ++++++++++++ .../Orchestration/DefaultComponentFactory.cs | 15 + .../Hosts/PowerStateWatcherHost.cs | 286 ++++++++++++++++++ .../Orchestration/SignalActivityClassifier.cs | 5 + .../PowerStateRuleTests.cs | 222 ++++++++++++++ .../Config/GetAgentConfigFunction.cs | 2 +- .../EventIngestProcessor.Classification.cs | 3 + .../src/resource-catalog.ts | 1 + .../AutopilotMonitor.Shared/Constants.cs | 1 + .../Models/Config/AgentConfigResponse.cs | 10 + .../app/gather-rules/eventTypes.ts | 2 + .../utils/shared-manifests.generated.ts | 1 + .../utils/shared-manifests.json | 1 + 24 files changed, 1457 insertions(+), 2 deletions(-) create mode 100644 docs/agent/power-state-watcher.md create mode 100644 rules/analyze/ANALYZE-DEV-009.json create mode 100644 rules/analyze/ANALYZE-DEV-010.json create mode 100644 src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/SystemSignals/PowerStateTransitionTrackerTests.cs create mode 100644 src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/PowerStateWatcherHostTests.cs create mode 100644 src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/PowerStateTransitionTracker.cs create mode 100644 src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/PowerStateWatcherHost.cs create mode 100644 src/Backend/AutopilotMonitor.Functions.Tests/PowerStateRuleTests.cs diff --git a/docs/agent/index.md b/docs/agent/index.md index b744a477..192ec4eb 100644 --- a/docs/agent/index.md +++ b/docs/agent/index.md @@ -8,5 +8,6 @@ How the on-device agent works: * [Hello Wizard Un-Skip](hello-wizard-unskip.md) - why a single "WHfB disabled" policy read never decides completion: the HelloWizardStarted signal (prevention + cure) and the HelloTracker confirmation second read. * [Autopilot ZTD Diagnostics](autopilot-ztd-diagnostics.md) - Windows' own diagnostic surfaces for the profile-download flow: ModernDeployment event IDs (807/815/164/...), the Diagnostics\Autopilot registry key, deployment-service endpoints, and the known-issue error-code map. * [MDM Reboot Coalescing](mdm-reboot-coalescing.md) - attributing the mid-ESP coalesced reboot ("second sign-in") to the device-assigned policy URIs that forced it: aggregated 2800 observations (neutral, flush-time watermark) + ANALYZE-ESP-005 gated on an actually-observed reboot. +* [Power-State Watcher](power-state-watcher.md) - live AC↔battery transitions and 50/30/15% downward threshold crossings via a WMI push subscription (no polling): power_state_change semantics (latch/debounce/cap, never armed without a battery) and the single-condition analyze rules ANALYZE-DEV-009/-010 on top. Reading order for newcomers: overview → decision engine → persistence. diff --git a/docs/agent/power-state-watcher.md b/docs/agent/power-state-watcher.md new file mode 100644 index 00000000..57534cf5 --- /dev/null +++ b/docs/agent/power-state-watcher.md @@ -0,0 +1,95 @@ +--- +type: Concept +title: Power-State Watcher — live AC/battery transitions and threshold events during enrollment +description: How the agent observes AC↔battery switches and downward battery-threshold crossings mid-enrollment via a WMI push subscription (no polling), the power_state_change event semantics (transition field, 50/30/15 ladder, latch/debounce/cap), and the two analyze rules on top (ANALYZE-DEV-009/-010). +resource: /src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/PowerStateWatcherHost.cs +tags: + - agent + - power + - battery + - collector + - wmi + - analyze-rules +timestamp: 2026-08-21T00:00:00+02:00 +--- + +# Power-State Watcher + +The startup probe (`power_state_check`, one-shot from `StartupEnvironmentProbes` via +`PowerStateProbe`/`GetSystemPowerStatus`) only captures the power state at agent start. A +device unplugged mid-enrollment — or draining its battery across a multi-hour ESP — was +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 + 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 + WMI, a 5 s trailing-edge debounce timer (dock-flap AC→battery→AC inside the window + collapses to a no-diff) and the emit path (`InformationalEventPost`, single rail). +- **No battery / probe error ⇒ WMI is never armed** — desktops and VMs pay nothing. WMI + arming runs on a background thread (WinMgmt can hang in OOBE; `ConsoleBypassWatcher` + precedent); an arm failure emits one `collector_degraded` (`watcher_arm_failed`). +- Registered as a plain always-on peripheral host after `NetworkChangeHost`, deliberately + NOT under `PeriodicCollectorLifecycleHost`: it is push-based (zero idle cost) and must be + alive precisely when the device idles on battery. Kill-switch: + `Collectors.EnablePowerStateWatcher` (default true, compile-time default like the other + collector toggles; ConfigVersion 38). + +# Event: power_state_change + +`data.transition` discriminates three shapes (payload always carries `onAcPower`, +`batteryPercent`, `isCharging`, `batteryLifeMinutes`; unknowns as `"unknown"`): + +| transition | Severity | Immediate | Notes | +|---|---|---|---| +| `ac_to_battery` | Warning | yes | one per accepted edge | +| `battery_to_ac` | Info | no | recovery | +| `threshold_crossed` | Info (50) / Warning (30) / Error (15) | 30+15 only | extra field `thresholdPercent` | + +Threshold semantics (tracker): + +- Ladder 50/30/15, evaluated only while ON battery; **each level latches once per agent + lifetime** (charge-up + re-drain does not re-emit — no plug-cycle alarm spam). +- A multi-level jump emits only the LOWEST newly-crossed level (60%→10% ⇒ one `15` event). +- Entering battery power already below a level counts as crossing it; the arm-time baseline + does too — an enrollment STARTING at 12% battery emits its `thresholdPercent: 15` event + immediately (the analyze rule would otherwise never see sessions that began low). +- Lifetime emission cap 20 (storm backstop against a flapping dock / dying battery + controller); the host logs one warning when it engages. + +Backend/agent hygiene: `power_state_change` is classified as non-activity on both sides — +`SignalActivityClassifier.NonActivityEventTypes` (agent: must not reset the +PeriodicCollectorLifecycleHost/StallProbeHost idle clocks) and +`EventIngestProcessor.IsPeriodicOrStallEvent` (backend: must not heal a Stalled session — +battery drain is not enrollment progress). + +# Analyze Rules + +- **ANALYZE-DEV-009** (high): battery crossed 15% while on battery — matches the + agent-stamped `thresholdPercent equals 15`. +- **ANALYZE-DEV-010** (warning): AC→battery switch — matches `transition equals + ac_to_battery`. +- Both: `evaluateOn: ["enrollment_end", "whiteglove_sealed", "on_event:power_state_change"]` + (live interim finding + terminal finalization), `markSessionAsFailedDefault: false`. +- Both are deliberately **single-required-condition**: `event_data` conditions scan events + independently (no same-instance join), so a two-condition rule (e.g. `transition == + threshold_crossed AND batteryPercent < 15`) could false-positive across two different + power events. `thresholdPercent` exists only on threshold events and its value pins the + ladder level, so one condition fully identifies the instance. +- `{{token}}` caveat: `AddDataFieldsToEvidence` whitelists only generic identifiers + (appId/errorCode/…), so `{{batteryPercent}}` would render literally. DEV-009 interpolates + `{{thresholdPercent}}` (the matched condition's own dataField); the exact percentage lives + on the linked event in the timeline. + +# Citations + +- `src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/PowerStateWatcherHost.cs` +- `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/PowerStateTransitionTracker.cs` +- `src/Agent/AutopilotMonitor.Agent.V2.Core/Runtime/PowerStateProbe.cs` (probe, unchanged) +- `rules/analyze/ANALYZE-DEV-009.json`, `rules/analyze/ANALYZE-DEV-010.json` +- [analyze-rule-triggers](../rules/analyze-rule-triggers.md) (interim-trigger design; the WG battery rule was its motivating case) diff --git a/docs/index.md b/docs/index.md index cef19ccb..763c6dfb 100644 --- a/docs/index.md +++ b/docs/index.md @@ -30,6 +30,7 @@ https://docs.autopilotmonitor.com (separate repository). * [CMTrace Time Resolution](agent/cmtrace-time-resolution.md) - Converting bias-less CMTrace local times to UTC without trusting any process's timezone belief: per-line self-anchoring on the 15-minute offset grid for provably fresh lines, uniform marked reader-zone fallback for backlog, and the provenance vocabulary (bias / line-anchored / reader-zone-fallback) that keeps every event re-derivable. * [Registry App-State Second Pillar](agent/registry-app-state-pillar.md) - IME's authoritative per-app state read straight from the registry (Win32Apps EnforcementStateMessage, EspTrackingWin32Apps, StatusServiceReports) via a snapshot-and-diff watcher; registry/log reconciliation doubles as the built-in IME log-pattern drift alarm. * [WDP Scenario Gates & Completion Backstop](agent/wdp-scenario-gates.md) - How the agent gates Autopilot/ESP-only machinery off on Windows Device Preparation sessions (deterministic ExecutionContext marker, fail-toward-Classic), which noise it suppresses (ZTD probe, esp_config unknown, ModernDeployment 100/417/418/1005, starved sweep), and the WDP completion model: High-confidence seed, desktop-based arm D, and the 30-min device_prep_completion backstop. +* [Power-State Watcher](agent/power-state-watcher.md) - Live AC↔battery transitions and 50/30/15% downward battery-threshold crossings during enrollment via a WMI Win32_PowerManagementEvent push subscription (zero polling, never armed on battery-less devices): the power_state_change event (transition field, per-level lifetime latch, 5s debounce, emission cap) and the analyze rules ANALYZE-DEV-009 (battery <15%, high) / ANALYZE-DEV-010 (AC→battery, warning) with on_event interim triggers. * [ESP Policy-Provider Stall Detection](agent/esp-policy-provider-stall.md) - The EnrollmentStatusTracking CSP's no-timeout Setup/Apps wait is keyed to the Intune-registered "Sidecar" provider by NAME: co-management's "ConfigMgr" parks the user ESP at "Apps (Identifying)" for days even with TrackingPoliciesCreated=1 (issue #106); the always-on two-arm esp_policy_provider_stalled tripwire (provider_incomplete | sidecar_provider_missing) and the SOFTWARE\Microsoft\Windows\Autopilot gather-rule allowlist entry that surface it. # Rules diff --git a/docs/log.md b/docs/log.md index 50869f09..18a25a53 100644 --- a/docs/log.md +++ b/docs/log.md @@ -2,6 +2,8 @@ ## 2026-08-21 +* **New**: `agent/power-state-watcher.md` — live AC/battery observation during enrollment: `PowerStateWatcherHost` subscribes to the WMI push `Win32_PowerManagementEvent EventCode 10` (no polling; re-probes `GetSystemPowerStatus` per push, 5 s trailing-edge debounce, never armed on battery-less devices, `Collectors.EnablePowerStateWatcher` kill-switch, ConfigVersion 38). The pure `PowerStateTransitionTracker` emits `power_state_change` with `transition` = ac_to_battery (Warning, immediate) / battery_to_ac (Info) / threshold_crossed at 50/30/15% (each level latched once per lifetime, lowest-crossed-only on jumps, baseline-below-level covered, emission cap 20). Classified non-activity on both sides (SignalActivityClassifier + IsPeriodicOrStallEvent). New analyze rules ANALYZE-DEV-009 (thresholdPercent=15, high) and ANALYZE-DEV-010 (transition=ac_to_battery, warning), both with `on_event:power_state_change` interim triggers and deliberately single-required-condition (event_data conditions have no same-instance join). + * **Update**: `rules/rule-id-namespace.md` — the export→paste round-trip is no longer lossy: the JSON editors now map rule-shaped exports into the form (`gatherRuleToForm` unflattens the nested gather `parameters` object, analyze `jsonToForm` maps `evaluateOn` onto the eval checkboxes; `tags` and the effective `markSessionAsFailed` carry into the created rule). Both editors still accept the serialized-form shape; edit-mode pastes merge, with an explicit `parameters`/`evaluateOn` winning over stale flat fields. * **New**: `rules/rule-id-namespace.md` — rule identity model: human-readable `ruleId` strings (no GUIDs), tenant isolation via Table Storage partitioning (`global` = built-in/community, tenantId = custom), the reserved built-in namespace from `RuleIdPolicy`, and the four collision guards: catalog-only global `RuleStats` aggregate (same-ID custom rules of unrelated tenants no longer sum into one `global_{ruleId}` row), combine.js duplicate/namespace fail, GitHub-reseed rejection of non-reserved IDs into the global partition, and the namespace gate on the cross-tenant RuleState GC. diff --git a/docs/rules/analyze-rule-triggers.md b/docs/rules/analyze-rule-triggers.md index 4e8e38a8..13eb6b47 100644 --- a/docs/rules/analyze-rule-triggers.md +++ b/docs/rules/analyze-rule-triggers.md @@ -98,7 +98,7 @@ Interim evaluation changes what a rule's conditions mean mid-flight. Three new l # Application to the Motivating Cases -- WG battery rule (sits-d): `evaluateOn: ["enrollment_end", "whiteglove_sealed"]` — technician gets the channel alert at the bench; Part-2 terminal pass finalizes or resolves the finding. +- WG battery rule (sits-d): shipped 2026-08-21 as ANALYZE-DEV-009 (battery below 15% while on battery) and ANALYZE-DEV-010 (AC→battery switch), both `evaluateOn: ["enrollment_end", "whiteglove_sealed", "on_event:power_state_change"]` on the live power watcher's `power_state_change` events (see `../agent/power-state-watcher.md`) — technician gets the channel alert at the bench; the terminal pass finalizes the finding. Both are deliberately single-required-condition rules: `event_data` conditions scan events independently (no same-instance join), so DEV-009 matches the agent-stamped `thresholdPercent` and DEV-010 matches `transition`. - Hybrid-affinity stuck (esa): ANALYZE-ID-004 gets `evaluateOn: ["enrollment_end", "on_event:hybrid_login_pending"]` — the second overdue-login event (threshold 65) produces a live finding on the still-InProgress session. ANALYZE-APP-015 stays `enrollment_end`-only until its conditions are hardened (lint 1). - Stuck after a forced mid-ESP reboot (sits-d Cloud PCs, 2026-08-19): `on_event:session_stalled` is the established trigger for the never-terminal stuck session. The agent emits `session_stalled` exactly once per session (60-minute stall probe), so the trigger is rare by construction and needs no throttle. Five Cloud PCs sat at `InProgress` with **zero findings** for hours — a mid-ESP reboot had killed the agent in `Stage=EspAccountSetup` — before the max-lifetime watchdog eventually terminalised them as `Incomplete`. ANALYZE-ESP-005 carries the trigger and fires on that stream at confidence 95 (verified against session `8110e262`): both its conditions (`mdm_policy_reboot_required.firstRebootUri` exists, `system_reboot_detected` count ≥ 1) are **monotonic** and it has no terminal preconditions, so lint 1 does not apply. ANALYZE-APP-016 is interim-safe for the same reason (`app_install_starved` count ≥ 1 only ever grows). diff --git a/rules/analyze/ANALYZE-DEV-009.json b/rules/analyze/ANALYZE-DEV-009.json new file mode 100644 index 00000000..0cc40820 --- /dev/null +++ b/rules/analyze/ANALYZE-DEV-009.json @@ -0,0 +1,48 @@ +{ + "$schema": "../schema/analyze-rule.schema.json", + "ruleId": "ANALYZE-DEV-009", + "title": "Battery Critically Low During Enrollment", + "description": "Detects that the device's battery dropped to 15% or less while enrolling on battery power (power_state_change threshold event from the agent's live power watcher). A device dying mid-enrollment leaves a half-provisioned state that typically needs a full reset; this rule surfaces the risk while there is still time to plug the device in. Uses a single required condition on the agent-stamped thresholdPercent field (the data field 'transition' on the event is unrelated to this rule's 'trigger' property).", + "severity": "high", + "category": "device", + "version": "1.0.0", + "enabled": true, + "evaluateOn": [ + "enrollment_end", + "whiteglove_sealed", + "on_event:power_state_change" + ], + "trigger": "single", + "baseConfidence": 90, + "confidenceThreshold": 60, + "conditions": [ + { + "signal": "battery_threshold_15", + "source": "event_data", + "eventType": "power_state_change", + "dataField": "thresholdPercent", + "operator": "equals", + "value": "15", + "required": true + } + ], + "explanation": "The device's battery fell below **{{thresholdPercent}}%** while the enrollment was running on battery power. If the battery drains completely mid-enrollment, the device is left in a half-provisioned state — apps, policies, or the user profile may be incomplete, and in most cases the device has to be reset and re-enrolled. The agent detected the threshold crossing live via a Windows power-status notification; the exact percentage at that moment is recorded on the matched power_state_change event in the session timeline.", + "remediation": [ + { + "title": "Secure power for the running enrollment", + "steps": [ + "Connect the device to AC power immediately — the enrollment continues normally once power is stable.", + "If the device shut down before completing, check whether the session reached the desktop; if not, reset the device (Autopilot reset or reimage) and re-enroll it plugged in.", + "For technician/White Glove flows, make AC power part of the bench checklist — pre-provisioning on battery is the most common source of this alert.", + "If this fires across many devices of one model, review that model's battery health (the hardware_spec event carries design vs. full-charge capacity)." + ] + } + ], + "markSessionAsFailedDefault": false, + "tags": [ + "device", + "power", + "battery", + "environment" + ] +} diff --git a/rules/analyze/ANALYZE-DEV-010.json b/rules/analyze/ANALYZE-DEV-010.json new file mode 100644 index 00000000..39683629 --- /dev/null +++ b/rules/analyze/ANALYZE-DEV-010.json @@ -0,0 +1,48 @@ +{ + "$schema": "../schema/analyze-rule.schema.json", + "ruleId": "ANALYZE-DEV-010", + "title": "Enrollment Switched From AC to Battery Power", + "description": "Detects that the device lost AC power and switched to battery while the enrollment was running (power_state_change event with transition ac_to_battery from the agent's live power watcher). Uses a single required condition so the match is pinned to one event instance; the event payload carries the battery percentage at the moment of the switch. The agent debounces power flapping (5s) so a brief unplug/replug does not produce this event.", + "severity": "warning", + "category": "device", + "version": "1.0.0", + "enabled": true, + "evaluateOn": [ + "enrollment_end", + "whiteglove_sealed", + "on_event:power_state_change" + ], + "trigger": "single", + "baseConfidence": 85, + "confidenceThreshold": 60, + "conditions": [ + { + "signal": "switched_to_battery", + "source": "event_data", + "eventType": "power_state_change", + "dataField": "transition", + "operator": "equals", + "value": "ac_to_battery", + "required": true + } + ], + "explanation": "The device switched from AC to battery power mid-enrollment. Enrollments are power-hungry — app downloads, Windows Update and disk encryption drain a battery quickly, and Windows may also throttle CPU and network on battery, slowing the enrollment down. The battery percentage at the moment of the switch is recorded on the matched power_state_change event in the session timeline; if the device stayed on battery, look there for subsequent threshold events (50/30/15%) — a later battery_to_ac event means power was restored.", + "remediation": [ + { + "title": "Restore AC power", + "steps": [ + "Reconnect the device to AC power; the enrollment continues normally.", + "Check the session timeline for a subsequent battery_to_ac event (power restored) or threshold_crossed events (battery draining) to judge urgency.", + "If this alert is frequent in a deployment area, check docking stations/USB-C hubs for loose power delivery — a flapping dock is a common cause.", + "Make 'plugged in during setup' part of the end-user/technician enrollment instructions." + ] + } + ], + "markSessionAsFailedDefault": false, + "tags": [ + "device", + "power", + "battery", + "environment" + ] +} diff --git a/rules/dist/analyze-rules.json b/rules/dist/analyze-rules.json index ab1e9784..ded7f32c 100644 --- a/rules/dist/analyze-rules.json +++ b/rules/dist/analyze-rules.json @@ -1584,6 +1584,100 @@ "known-issue" ] }, + { + "ruleId": "ANALYZE-DEV-009", + "title": "Battery Critically Low During Enrollment", + "description": "Detects that the device's battery dropped to 15% or less while enrolling on battery power (power_state_change threshold event from the agent's live power watcher). A device dying mid-enrollment leaves a half-provisioned state that typically needs a full reset; this rule surfaces the risk while there is still time to plug the device in. Uses a single required condition on the agent-stamped thresholdPercent field (the data field 'transition' on the event is unrelated to this rule's 'trigger' property).", + "severity": "high", + "category": "device", + "version": "1.0.0", + "enabled": true, + "evaluateOn": [ + "enrollment_end", + "whiteglove_sealed", + "on_event:power_state_change" + ], + "trigger": "single", + "baseConfidence": 90, + "confidenceThreshold": 60, + "conditions": [ + { + "signal": "battery_threshold_15", + "source": "event_data", + "eventType": "power_state_change", + "dataField": "thresholdPercent", + "operator": "equals", + "value": "15", + "required": true + } + ], + "explanation": "The device's battery fell below **{{thresholdPercent}}%** while the enrollment was running on battery power. If the battery drains completely mid-enrollment, the device is left in a half-provisioned state — apps, policies, or the user profile may be incomplete, and in most cases the device has to be reset and re-enrolled. The agent detected the threshold crossing live via a Windows power-status notification; the exact percentage at that moment is recorded on the matched power_state_change event in the session timeline.", + "remediation": [ + { + "title": "Secure power for the running enrollment", + "steps": [ + "Connect the device to AC power immediately — the enrollment continues normally once power is stable.", + "If the device shut down before completing, check whether the session reached the desktop; if not, reset the device (Autopilot reset or reimage) and re-enroll it plugged in.", + "For technician/White Glove flows, make AC power part of the bench checklist — pre-provisioning on battery is the most common source of this alert.", + "If this fires across many devices of one model, review that model's battery health (the hardware_spec event carries design vs. full-charge capacity)." + ] + } + ], + "markSessionAsFailedDefault": false, + "tags": [ + "device", + "power", + "battery", + "environment" + ] + }, + { + "ruleId": "ANALYZE-DEV-010", + "title": "Enrollment Switched From AC to Battery Power", + "description": "Detects that the device lost AC power and switched to battery while the enrollment was running (power_state_change event with transition ac_to_battery from the agent's live power watcher). Uses a single required condition so the match is pinned to one event instance; the event payload carries the battery percentage at the moment of the switch. The agent debounces power flapping (5s) so a brief unplug/replug does not produce this event.", + "severity": "warning", + "category": "device", + "version": "1.0.0", + "enabled": true, + "evaluateOn": [ + "enrollment_end", + "whiteglove_sealed", + "on_event:power_state_change" + ], + "trigger": "single", + "baseConfidence": 85, + "confidenceThreshold": 60, + "conditions": [ + { + "signal": "switched_to_battery", + "source": "event_data", + "eventType": "power_state_change", + "dataField": "transition", + "operator": "equals", + "value": "ac_to_battery", + "required": true + } + ], + "explanation": "The device switched from AC to battery power mid-enrollment. Enrollments are power-hungry — app downloads, Windows Update and disk encryption drain a battery quickly, and Windows may also throttle CPU and network on battery, slowing the enrollment down. The battery percentage at the moment of the switch is recorded on the matched power_state_change event in the session timeline; if the device stayed on battery, look there for subsequent threshold events (50/30/15%) — a later battery_to_ac event means power was restored.", + "remediation": [ + { + "title": "Restore AC power", + "steps": [ + "Reconnect the device to AC power; the enrollment continues normally.", + "Check the session timeline for a subsequent battery_to_ac event (power restored) or threshold_crossed events (battery draining) to judge urgency.", + "If this alert is frequent in a deployment area, check docking stations/USB-C hubs for loose power delivery — a flapping dock is a common cause.", + "Make 'plugged in during setup' part of the end-user/technician enrollment instructions." + ] + } + ], + "markSessionAsFailedDefault": false, + "tags": [ + "device", + "power", + "battery", + "environment" + ] + }, { "ruleId": "ANALYZE-ENRL-001", "title": "Enrollment Failed", diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/SystemSignals/PowerStateTransitionTrackerTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/SystemSignals/PowerStateTransitionTrackerTests.cs new file mode 100644 index 00000000..897e74e6 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/SystemSignals/PowerStateTransitionTrackerTests.cs @@ -0,0 +1,258 @@ +#nullable enable +using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals; +using AutopilotMonitor.Agent.V2.Core.Runtime; +using AutopilotMonitor.Shared.Models; +using Xunit; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals +{ + /// + /// Pure state machine behind the power watcher: AC↔battery edges, the 50/30/15 threshold + /// ladder (latch once per lifetime, lowest-crossed-only on multi-level jumps, baseline + /// coverage for sessions starting low), null-percent tolerance and the storm cap. + /// + public sealed class PowerStateTransitionTrackerTests + { + private static PowerStateResult Ac(int? percent = 80, bool charging = true) => new PowerStateResult + { + OnAcPower = true, + HasBattery = true, + BatteryPercent = percent, + IsCharging = charging, + BatteryLifeMinutes = null, + }; + + private static PowerStateResult Battery(int? percent, int? lifeMinutes = 90) => new PowerStateResult + { + OnAcPower = false, + HasBattery = true, + BatteryPercent = percent, + IsCharging = false, + BatteryLifeMinutes = lifeMinutes, + }; + + [Fact] + public void Baseline_on_ac_is_silent() + { + var tracker = new PowerStateTransitionTracker(); + Assert.Empty(tracker.Baseline(Ac())); + } + + [Fact] + public void Baseline_without_battery_or_with_probe_error_is_silent() + { + var tracker = new PowerStateTransitionTracker(); + Assert.Empty(tracker.Baseline(new PowerStateResult { OnAcPower = true, HasBattery = false })); + Assert.Empty(tracker.Baseline(new PowerStateResult { ProbeError = "boom" })); + } + + [Fact] + public void Baseline_on_battery_above_ladder_is_silent() + { + var tracker = new PowerStateTransitionTracker(); + Assert.Empty(tracker.Baseline(Battery(80))); + } + + [Fact] + public void Baseline_at_12_percent_emits_exactly_the_15_threshold() + { + var tracker = new PowerStateTransitionTracker(); + + var emissions = tracker.Baseline(Battery(12)); + + var e = Assert.Single(emissions); + Assert.Equal(PowerStateTransitionTracker.TransitionThresholdCrossed, e.Transition); + Assert.Equal(15, e.ThresholdPercent); + Assert.Equal(12, e.BatteryPercent); + Assert.Equal(EventSeverity.Error, e.Severity); + Assert.True(e.ImmediateUpload); + } + + [Fact] + public void Baseline_at_25_percent_emits_30_and_latches_50() + { + var tracker = new PowerStateTransitionTracker(); + + var baseline = tracker.Baseline(Battery(25)); + var e = Assert.Single(baseline); + Assert.Equal(30, e.ThresholdPercent); + Assert.Equal(EventSeverity.Warning, e.Severity); + Assert.True(e.ImmediateUpload); + + // 50 and 30 are latched by the baseline; draining to 40% stays silent, 14% emits 15. + Assert.Empty(tracker.Evaluate(Battery(40))); + var next = Assert.Single(tracker.Evaluate(Battery(14))); + Assert.Equal(15, next.ThresholdPercent); + } + + [Fact] + public void Ac_to_battery_emits_warning_with_immediate_upload() + { + var tracker = new PowerStateTransitionTracker(); + tracker.Baseline(Ac(63)); + + var e = Assert.Single(tracker.Evaluate(Battery(63))); + + Assert.Equal(PowerStateTransitionTracker.TransitionAcToBattery, e.Transition); + Assert.Null(e.ThresholdPercent); + Assert.Equal(EventSeverity.Warning, e.Severity); + Assert.True(e.ImmediateUpload); + Assert.False(e.OnAcPower); + Assert.Equal(63, e.BatteryPercent); + } + + [Fact] + public void Battery_to_ac_emits_info_without_immediate_upload() + { + var tracker = new PowerStateTransitionTracker(); + tracker.Baseline(Battery(70)); + + var e = Assert.Single(tracker.Evaluate(Ac(70))); + + Assert.Equal(PowerStateTransitionTracker.TransitionBatteryToAc, e.Transition); + Assert.Equal(EventSeverity.Info, e.Severity); + Assert.False(e.ImmediateUpload); + } + + [Fact] + public void No_diff_stays_silent() + { + var tracker = new PowerStateTransitionTracker(); + tracker.Baseline(Ac()); + + Assert.Empty(tracker.Evaluate(Ac())); + Assert.Empty(tracker.Evaluate(Ac(79))); + } + + [Fact] + public void Unplug_below_threshold_emits_transition_and_threshold_together() + { + var tracker = new PowerStateTransitionTracker(); + tracker.Baseline(Ac(10)); + + var emissions = tracker.Evaluate(Battery(10)); + + Assert.Equal(2, emissions.Count); + Assert.Equal(PowerStateTransitionTracker.TransitionAcToBattery, emissions[0].Transition); + Assert.Equal(PowerStateTransitionTracker.TransitionThresholdCrossed, emissions[1].Transition); + Assert.Equal(15, emissions[1].ThresholdPercent); + } + + [Fact] + public void Multi_level_drop_emits_lowest_crossed_level_only() + { + var tracker = new PowerStateTransitionTracker(); + tracker.Baseline(Battery(60)); + + var e = Assert.Single(tracker.Evaluate(Battery(10))); + + Assert.Equal(15, e.ThresholdPercent); + // All levels are latched — recovering and re-draining emits nothing more. + tracker.Evaluate(Ac(90)); + Assert.Single(tracker.Evaluate(Battery(45)), x => x.Transition == PowerStateTransitionTracker.TransitionAcToBattery); + Assert.Empty(tracker.Evaluate(Battery(13))); + } + + [Fact] + public void Each_level_latches_once_per_lifetime() + { + var tracker = new PowerStateTransitionTracker(); + tracker.Baseline(Battery(70)); + + Assert.Single(tracker.Evaluate(Battery(48))); // 50 + Assert.Empty(tracker.Evaluate(Battery(47))); // still inside 50, no re-emit + Assert.Single(tracker.Evaluate(Battery(29))); // 30 + Assert.Single(tracker.Evaluate(Battery(15))); // 15 (boundary: <= level) + Assert.Empty(tracker.Evaluate(Battery(5))); // ladder exhausted + } + + [Fact] + public void Threshold_severity_ladder_is_info_warning_error() + { + var tracker = new PowerStateTransitionTracker(); + tracker.Baseline(Battery(70)); + + var fifty = Assert.Single(tracker.Evaluate(Battery(50))); + Assert.Equal(EventSeverity.Info, fifty.Severity); + Assert.False(fifty.ImmediateUpload); + + var thirty = Assert.Single(tracker.Evaluate(Battery(30))); + Assert.Equal(EventSeverity.Warning, thirty.Severity); + Assert.True(thirty.ImmediateUpload); + + var fifteen = Assert.Single(tracker.Evaluate(Battery(15))); + Assert.Equal(EventSeverity.Error, fifteen.Severity); + Assert.True(fifteen.ImmediateUpload); + } + + [Fact] + public void Null_battery_percent_skips_thresholds_but_ac_diff_still_works() + { + var tracker = new PowerStateTransitionTracker(); + tracker.Baseline(Ac(null)); + + var e = Assert.Single(tracker.Evaluate(Battery(null, lifeMinutes: null))); + + Assert.Equal(PowerStateTransitionTracker.TransitionAcToBattery, e.Transition); + Assert.Null(e.BatteryPercent); + Assert.Contains("battery level unknown", e.Message); + Assert.Empty(tracker.Evaluate(Battery(null, lifeMinutes: null))); + } + + [Fact] + public void Probe_error_mid_run_preserves_state() + { + var tracker = new PowerStateTransitionTracker(); + tracker.Baseline(Ac()); + + Assert.Empty(tracker.Evaluate(new PowerStateResult { ProbeError = "transient" })); + // State was not corrupted by the error — the real transition still diffs cleanly. + Assert.Single(tracker.Evaluate(Battery(60))); + } + + [Fact] + public void Evaluate_without_baseline_latches_like_a_baseline() + { + var tracker = new PowerStateTransitionTracker(); + + var e = Assert.Single(tracker.Evaluate(Battery(12))); + + // No phantom ac_to_battery from the implicit default state — only the threshold. + Assert.Equal(PowerStateTransitionTracker.TransitionThresholdCrossed, e.Transition); + } + + [Fact] + public void Emission_cap_suppresses_and_flips_flag_once() + { + var tracker = new PowerStateTransitionTracker(maxEmissions: 4); + tracker.Baseline(Ac()); + + // Flapping power source: each edge is one emission. + for (var i = 0; i < 2; i++) + { + Assert.Single(tracker.Evaluate(Battery(80))); + Assert.Single(tracker.Evaluate(Ac(80))); + } + Assert.False(tracker.EmissionCapReached); + + Assert.Empty(tracker.Evaluate(Battery(80))); + Assert.True(tracker.EmissionCapReached); + Assert.Empty(tracker.Evaluate(Ac(80))); + } + + [Fact] + public void Message_carries_percent_and_transition_context() + { + var tracker = new PowerStateTransitionTracker(); + tracker.Baseline(Ac(62)); + + var toBattery = Assert.Single(tracker.Evaluate(Battery(62))); + Assert.Contains("AC to battery", toBattery.Message); + Assert.Contains("battery 62%", toBattery.Message); + + var backToAc = Assert.Single(tracker.Evaluate(Ac(63))); + Assert.Contains("back to AC", backToAc.Message); + Assert.Contains("charging", backToAc.Message); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/PowerStateWatcherHostTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/PowerStateWatcherHostTests.cs new file mode 100644 index 00000000..ab8ef4c3 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/PowerStateWatcherHostTests.cs @@ -0,0 +1,162 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Linq; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Agent.V2.Core.Orchestration; +using AutopilotMonitor.Agent.V2.Core.Runtime; +using AutopilotMonitor.Agent.V2.Core.Tests.Harness; +using AutopilotMonitor.DecisionCore.Engine; +using AutopilotMonitor.DecisionCore.Signals; +using AutopilotMonitor.Shared; +using Xunit; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration +{ + /// + /// PowerStateWatcherHost — no-battery/probe-error devices never arm, a low-battery baseline + /// emits its threshold event onto the single rail, and debounced ticks translate tracker + /// emissions into power_state_change events. WMI arming itself stays untested + /// (ConsoleBypassWatcher precedent); StartCore(armWmi: false) exercises everything else. + /// + public sealed class PowerStateWatcherHostTests : IDisposable + { + private static readonly DateTime At = new DateTime(2026, 8, 21, 9, 0, 0, DateTimeKind.Utc); + + private readonly TempDirectory _tmp = new TempDirectory(); + private readonly FakeSignalIngressSink _sink = new FakeSignalIngressSink(); + private readonly AgentLogger _logger; + private PowerStateResult _probeResult = new PowerStateResult { OnAcPower = true, HasBattery = true, BatteryPercent = 80 }; + + public PowerStateWatcherHostTests() + { + _logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info); + } + + public void Dispose() => _tmp.Dispose(); + + private PowerStateWatcherHost Build() => new PowerStateWatcherHost( + sessionId: "sess-power", + tenantId: "tenant-power", + ingress: _sink, + clock: new VirtualClock(At), + logger: _logger, + probe: () => _probeResult); + + private IReadOnlyList PowerEvents() => + _sink.Posted.Where(p => + p.Kind == DecisionSignalKind.InformationalEvent + && p.Payload != null + && p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et) + && et == Constants.EventTypes.PowerStateChange).ToList(); + + [Fact] + public void No_battery_emits_nothing() + { + _probeResult = new PowerStateResult { OnAcPower = true, HasBattery = false }; + + Build().StartCore(armWmi: false); + + Assert.Empty(_sink.Posted); + } + + [Fact] + public void Probe_error_emits_nothing() + { + _probeResult = new PowerStateResult { ProbeError = "GetSystemPowerStatus returned false" }; + + Build().StartCore(armWmi: false); + + Assert.Empty(_sink.Posted); + } + + [Fact] + public void Baseline_below_threshold_emits_threshold_event_with_immediate_upload() + { + _probeResult = new PowerStateResult { OnAcPower = false, HasBattery = true, BatteryPercent = 12, BatteryLifeMinutes = 34 }; + + Build().StartCore(armWmi: false); + + var s = Assert.Single(PowerEvents()); + Assert.Equal("Error", s.Payload![SignalPayloadKeys.Severity]); + Assert.Equal("true", s.Payload![SignalPayloadKeys.ImmediateUpload]); + Assert.Contains("15%", s.Payload![SignalPayloadKeys.Message]); + + var data = (IReadOnlyDictionary)s.TypedPayload!; + Assert.Equal("threshold_crossed", data["transition"]); + Assert.Equal(15, data["thresholdPercent"]); + Assert.Equal(12, data["batteryPercent"]); + Assert.Equal(false, data["onAcPower"]); + Assert.Equal(34, data["batteryLifeMinutes"]); + } + + [Fact] + public void Tick_after_unplug_emits_ac_to_battery_warning() + { + var host = Build(); + host.StartCore(armWmi: false); + Assert.Empty(PowerEvents()); + + _probeResult = new PowerStateResult { OnAcPower = false, HasBattery = true, BatteryPercent = 79 }; + host.TickForTest(); + + var s = Assert.Single(PowerEvents()); + Assert.Equal("Warning", s.Payload![SignalPayloadKeys.Severity]); + Assert.Equal("true", s.Payload![SignalPayloadKeys.ImmediateUpload]); + + var data = (IReadOnlyDictionary)s.TypedPayload!; + Assert.Equal("ac_to_battery", data["transition"]); + Assert.Equal(79, data["batteryPercent"]); + Assert.False(data.ContainsKey("thresholdPercent")); + + // Replug: Info recovery event without immediate upload. + _probeResult = new PowerStateResult { OnAcPower = true, HasBattery = true, BatteryPercent = 79, IsCharging = true }; + host.TickForTest(); + + Assert.Equal(2, PowerEvents().Count); + var back = PowerEvents()[1]; + Assert.Equal("Info", back.Payload![SignalPayloadKeys.Severity]); + Assert.Equal("false", back.Payload![SignalPayloadKeys.ImmediateUpload]); + Assert.Equal("battery_to_ac", ((IReadOnlyDictionary)back.TypedPayload!)["transition"]); + } + + [Fact] + public void Tick_with_unchanged_state_emits_nothing() + { + var host = Build(); + host.StartCore(armWmi: false); + + host.TickForTest(); + host.TickForTest(); + + Assert.Empty(PowerEvents()); + } + + [Fact] + public void Stopped_host_ignores_ticks() + { + var host = Build(); + host.StartCore(armWmi: false); + host.Stop(); + + _probeResult = new PowerStateResult { OnAcPower = false, HasBattery = true, BatteryPercent = 50 }; + host.TickForTest(); + + Assert.Empty(PowerEvents()); + } + + [Fact] + public void Unknown_battery_percent_serializes_as_unknown() + { + var host = Build(); + host.StartCore(armWmi: false); + + _probeResult = new PowerStateResult { OnAcPower = false, HasBattery = true, BatteryPercent = null, BatteryLifeMinutes = null }; + host.TickForTest(); + + var data = (IReadOnlyDictionary)Assert.Single(PowerEvents()).TypedPayload!; + Assert.Equal("unknown", data["batteryPercent"]); + Assert.Equal("unknown", data["batteryLifeMinutes"]); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/SignalActivityClassifierTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/SignalActivityClassifierTests.cs index c5de6b2c..785d1860 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/SignalActivityClassifierTests.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/SignalActivityClassifierTests.cs @@ -35,6 +35,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration // OS-eventlog forwarding — observability, not enrollment progress (session 8bc1180f: // an EventID-100 burst must not keep the periodic collectors alive). [InlineData("modern_deployment_log")] + // Environment observation — battery drain on an idle device must not reset idle clocks. + [InlineData("power_state_change")] // Agent health / control / transport — not device/enrollment progress (P2). [InlineData("collector_degraded")] [InlineData("telemetry_upload_poisoned")] diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/PowerStateTransitionTracker.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/PowerStateTransitionTracker.cs new file mode 100644 index 00000000..e87f92a0 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/PowerStateTransitionTracker.cs @@ -0,0 +1,197 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Linq; +using AutopilotMonitor.Agent.V2.Core.Runtime; +using AutopilotMonitor.Shared.Models; + +namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals +{ + /// + /// Pure state machine behind PowerStateWatcherHost: diffs successive + /// snapshots into power_state_change emissions. + /// No WMI, no timers, no logging — fully deterministic and unit-testable. + /// + /// Semantics: + /// + /// AC↔battery transitions — one emission per accepted edge + /// (ac_to_battery Warning/immediate, battery_to_ac Info). + /// Threshold ladder (default 50/30/15 %) — evaluated only while ON battery; + /// each level latches once per tracker lifetime (no re-emit when the device charges + /// back up and drains again — avoids plug-cycle alarm spam). A multi-level jump + /// emits only the LOWEST newly-crossed level. Entering battery power already below + /// a level counts as crossing it (covers "unplugged at 12 %" and, via + /// , "enrollment started at 12 %"). + /// Storm cap — a lifetime emission cap (default 20) as backstop against a + /// flapping dock / dying battery controller; flips + /// once so the host can log a single warning. + /// + /// A snapshot with is ignored (previous state + /// preserved); a null skips threshold + /// evaluation but AC diffing still works. + /// + /// + internal sealed class PowerStateTransitionTracker + { + public const string TransitionAcToBattery = "ac_to_battery"; + public const string TransitionBatteryToAc = "battery_to_ac"; + public const string TransitionThresholdCrossed = "threshold_crossed"; + + internal static readonly int[] DefaultThresholdLadder = { 50, 30, 15 }; + internal const int DefaultMaxEmissions = 20; + + private readonly int[] _ladder; // strictly descending + private readonly int _maxEmissions; + private readonly HashSet _latchedLevels = new HashSet(); + + private bool _hasState; + private bool _lastOnAc; + private int _emitted; + + public PowerStateTransitionTracker(IReadOnlyList? thresholdLadder = null, int maxEmissions = DefaultMaxEmissions) + { + _ladder = (thresholdLadder ?? DefaultThresholdLadder).OrderByDescending(l => l).ToArray(); + _maxEmissions = maxEmissions; + } + + /// True once the lifetime emission cap suppressed at least one emission. + public bool EmissionCapReached { get; private set; } + + /// + /// Latches the arm-time state silently — except when the device is already on battery + /// at/below a ladder level, which yields one threshold emission for the lowest crossed + /// level (an enrollment STARTING at 12 % must still produce the event the analyze rule + /// matches on). No AC-transition emission is ever produced from a baseline. + /// + public IReadOnlyList Baseline(PowerStateResult probe) + { + if (probe == null || probe.ProbeError != null || !probe.HasBattery) + return Array.Empty(); + + _hasState = true; + _lastOnAc = probe.OnAcPower; + + var emissions = new List(); + EvaluateThresholds(probe, emissions); + return emissions; + } + + /// + /// Diffs a fresh (host-debounced) snapshot against the last accepted state and returns + /// 0..n emissions — an AC transition and a threshold crossing can co-occur (unplugged + /// at 10 % ⇒ ac_to_battery + threshold_crossed:15). + /// + public IReadOnlyList Evaluate(PowerStateResult probe) + { + if (probe == null || probe.ProbeError != null) + return Array.Empty(); + if (!_hasState) + return Baseline(probe); + + var emissions = new List(); + + if (_lastOnAc && !probe.OnAcPower) + { + Emit(emissions, new PowerStateEmission( + transition: TransitionAcToBattery, + thresholdPercent: null, + probe: probe, + severity: EventSeverity.Warning, + immediateUpload: true, + message: $"Power source switched from AC to battery ({FormatPercent(probe)} remaining) during enrollment")); + } + else if (!_lastOnAc && probe.OnAcPower) + { + Emit(emissions, new PowerStateEmission( + transition: TransitionBatteryToAc, + thresholdPercent: null, + probe: probe, + severity: EventSeverity.Info, + immediateUpload: false, + message: $"Power source switched back to AC ({FormatPercent(probe)}{(probe.IsCharging ? ", charging" : string.Empty)})")); + } + + _lastOnAc = probe.OnAcPower; + EvaluateThresholds(probe, emissions); + return emissions; + } + + /// + /// While on battery with a known percentage: emit the LOWEST ladder level at/above the + /// current charge that is not yet latched, then latch every level at/above the charge + /// (a 60 %→10 % jump emits threshold 15 only, latching 50/30/15). + /// + private void EvaluateThresholds(PowerStateResult probe, List emissions) + { + if (probe.OnAcPower || !probe.BatteryPercent.HasValue) return; + var percent = probe.BatteryPercent.Value; + + int? lowestCrossed = null; + foreach (var level in _ladder) // descending — the last match is the lowest crossed level + { + if (percent <= level && !_latchedLevels.Contains(level)) + lowestCrossed = level; + } + if (!lowestCrossed.HasValue) return; + + foreach (var level in _ladder) + { + if (percent <= level) _latchedLevels.Add(level); + } + + var threshold = lowestCrossed.Value; + var severity = threshold <= 15 ? EventSeverity.Error + : threshold <= 30 ? EventSeverity.Warning + : EventSeverity.Info; + Emit(emissions, new PowerStateEmission( + transition: TransitionThresholdCrossed, + thresholdPercent: threshold, + probe: probe, + severity: severity, + immediateUpload: severity >= EventSeverity.Warning, + message: $"Battery dropped below {threshold}% ({FormatPercent(probe)} remaining) while enrolling on battery power")); + } + + private void Emit(List emissions, PowerStateEmission emission) + { + if (_emitted >= _maxEmissions) + { + EmissionCapReached = true; + return; + } + _emitted++; + emissions.Add(emission); + } + + private static string FormatPercent(PowerStateResult probe) + => probe.BatteryPercent.HasValue ? $"battery {probe.BatteryPercent.Value}%" : "battery level unknown"; + } + + /// One decided power_state_change emission — the host maps this 1:1 onto an event. + internal sealed class PowerStateEmission + { + public PowerStateEmission(string transition, int? thresholdPercent, PowerStateResult probe, + EventSeverity severity, bool immediateUpload, string message) + { + Transition = transition; + ThresholdPercent = thresholdPercent; + OnAcPower = probe.OnAcPower; + BatteryPercent = probe.BatteryPercent; + IsCharging = probe.IsCharging; + BatteryLifeMinutes = probe.BatteryLifeMinutes; + Severity = severity; + ImmediateUpload = immediateUpload; + Message = message; + } + + public string Transition { get; } + public int? ThresholdPercent { get; } + public bool OnAcPower { get; } + public int? BatteryPercent { get; } + public bool IsCharging { get; } + public int? BatteryLifeMinutes { get; } + public EventSeverity Severity { get; } + public bool ImmediateUpload { get; } + public string Message { get; } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DefaultComponentFactory.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DefaultComponentFactory.cs index 10eee82a..81bcf624 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DefaultComponentFactory.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DefaultComponentFactory.cs @@ -595,6 +595,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration logger: logger, apiBaseUrl: _agentConfig.ApiBaseUrl)); + // Live AC/battery watcher — WMI Win32_PowerManagementEvent push (no polling), emits + // power_state_change on AC↔battery transitions and 50/30/15 % downward threshold + // crossings. Deliberately NOT under PeriodicCollectorLifecycleHost: it is push-based + // (zero idle cost) and must stay alive precisely when the device sits idle on battery. + // Never arms on devices without a battery. Kill-switchable via remote config. + if (collectors.EnablePowerStateWatcher) + { + hosts.Add(new PowerStateWatcherHost( + sessionId: sessionId, + tenantId: tenantId, + ingress: ingress, + clock: clock, + logger: logger)); + } + // Microsoft 365 Apps (Office C2R) install detector — event-driven (Rev 2): woken by a WMI // Win32_ProcessStartTrace push on OfficeC2RClient.exe (+ startup probe), progress via // RegNotifyChangeKeyValue, stop via Process.Exited. No idle polling. Constructed first so diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/PowerStateWatcherHost.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/PowerStateWatcherHost.cs new file mode 100644 index 00000000..430685c5 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/PowerStateWatcherHost.cs @@ -0,0 +1,286 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Management; +using System.Threading; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals; +using AutopilotMonitor.Agent.V2.Core.Runtime; +using AutopilotMonitor.DecisionCore.Engine; +using AutopilotMonitor.Shared; +using AutopilotMonitor.Shared.Models; + +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 + /// power_state_check (StartupEnvironmentProbes), which stays the baseline event. + /// + /// Devices without a battery (or with a failing probe) never arm the WMI subscription — zero + /// overhead on desktops/VMs. WMI arming runs on a background thread (WinMgmt can hang in OOBE; + /// same rationale as ConsoleBypassWatcher), and an arm failure is surfaced once as + /// collector_degraded. A 5 s trailing-edge debounce collapses docking flaps to a + /// no-diff; the tracker's lifetime emission cap is the storm backstop. + /// + /// + internal sealed class PowerStateWatcherHost : ICollectorHost + { + public string Name => "PowerStateWatcher"; + + private static readonly TimeSpan DebounceDelay = TimeSpan.FromSeconds(5); + + private readonly string _sessionId; + private readonly string _tenantId; + private readonly AgentLogger _logger; + private readonly InformationalEventPost _post; + private readonly Func _probe; + private readonly PowerStateTransitionTracker _tracker = new PowerStateTransitionTracker(); + private readonly object _lock = new object(); + private readonly object _tickLock = new object(); + + private ManagementEventWatcher? _watcher; + private Timer? _debounceTimer; + private bool _started; + private bool _disposed; + private bool _capWarned; + + /// Power-state snapshot source; test seam, defaults to + /// . The ctor never calls it (the factory constructs + /// hosts eagerly; all probing happens in 's background thread). + public PowerStateWatcherHost( + string sessionId, + string tenantId, + ISignalIngressSink ingress, + IClock clock, + AgentLogger logger, + Func? probe = null) + { + if (ingress == null) throw new ArgumentNullException(nameof(ingress)); + if (clock == null) throw new ArgumentNullException(nameof(clock)); + _sessionId = sessionId; + _tenantId = tenantId; + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _post = new InformationalEventPost(ingress, clock, logger); + _probe = probe ?? PowerStateProbe.Probe; + } + + public void Start() + { + lock (_lock) + { + if (_started || _disposed) return; + _started = true; + } + + // Baseline probe + WMI arming are synchronous WinMgmt-adjacent operations and WinMgmt + // can hang during OOBE — never run them inline on the orchestrator's sequential + // host-start loop (ConsoleBypassWatcher precedent). Disposal racing the background + // start is handled by the _disposed re-checks inside StartCore. + System.Threading.Tasks.Task.Run(() => StartCore(armWmi: true)); + } + + /// Internal for tests: the synchronous start body; tests pass + /// = false so no real WMI subscription is created + /// (precedent: ConsoleBypassWatcher leaves WMI arming untested). + internal void StartCore(bool armWmi) + { + PowerStateResult baseline; + try + { + baseline = _probe(); + } + catch (Exception ex) + { + _logger.Warning($"[{Name}] baseline probe threw ({ex.Message}) — power-state watcher not armed"); + return; + } + + if (baseline.ProbeError != null) + { + _logger.Info($"[{Name}] power probe failed ({baseline.ProbeError}) — watcher not armed"); + return; + } + if (!baseline.HasBattery) + { + _logger.Info($"[{Name}] no battery present (desktop/VM) — watcher not armed"); + return; + } + + // Latch the baseline BEFORE arming so the first WMI push diffs against a real state. + // A session already on battery below a ladder level emits its threshold event here. + EmitAll(_tracker.Baseline(baseline)); + + if (!armWmi) return; + + try + { + var query = new WqlEventQuery("SELECT * FROM Win32_PowerManagementEvent WHERE EventCode = 10"); + var watcher = new ManagementEventWatcher(query); + watcher.EventArrived += OnPowerStatusChange; + + lock (_lock) + { + if (_disposed) + { + // Dispose won the race against the background start — don't arm. + try { watcher.EventArrived -= OnPowerStatusChange; } catch { } + try { watcher.Dispose(); } catch { } + return; + } + _watcher = watcher; + } + + watcher.Start(); + + // Re-check after arming: Dispose() may have run between the lock release above and + // Start() — it then stopped a not-yet-armed watcher and this thread armed it right + // back. If disposal won that race, tear the watcher down here. + bool disposedDuringArm; + lock (_lock) + { + disposedDuringArm = _disposed; + if (disposedDuringArm) _watcher = null; + } + if (disposedDuringArm) + { + try { watcher.EventArrived -= OnPowerStatusChange; } catch { } + try { watcher.Stop(); } catch { } + try { watcher.Dispose(); } catch { } + return; + } + + _logger.Info($"[{Name}] watching Win32_PowerManagementEvent (EventCode 10) — " + + $"baseline: {(baseline.OnAcPower ? "AC" : "battery")}, {baseline.BatteryPercent?.ToString() ?? "?"}%"); + } + 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"); + lock (_lock) { _watcher = null; } + CollectorDegradationReporter.Report(_post, _sessionId, _tenantId, Name, "watcher_arm_failed", ex); + } + } + + 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. + try { ScheduleDebouncedTick(); } + catch (Exception ex) { _logger.Debug($"[{Name}] power event handler error: {ex.Message}"); } + } + + private void ScheduleDebouncedTick() + { + lock (_lock) { if (_disposed) return; } + // One trailing-edge tick per change burst (ImeRegistryAppStateHost pattern): a + // dock-flap AC→battery→AC inside the window collapses to a no-diff in the tracker. + var timer = _debounceTimer; + if (timer == null) + { + var created = new Timer(_ => DebouncedTick(), null, DebounceDelay, Timeout.InfiniteTimeSpan); + if (Interlocked.CompareExchange(ref _debounceTimer, created, null) != null) + created.Dispose(); + else + return; + timer = _debounceTimer; + } + try { timer?.Change(DebounceDelay, Timeout.InfiniteTimeSpan); } + catch (ObjectDisposedException) { /* stopping */ } + } + + /// Internal for tests: runs one debounced probe/diff/emit cycle synchronously, + /// exactly what the trailing-edge timer callback executes after a WMI push. + internal void TickForTest() => DebouncedTick(); + + private void DebouncedTick() + { + lock (_lock) { if (_disposed) return; } + try + { + IReadOnlyList emissions; + lock (_tickLock) // tracker is not thread-safe; ticks must not interleave + { + emissions = _tracker.Evaluate(_probe()); + } + EmitAll(emissions); + + if (_tracker.EmissionCapReached && !_capWarned) + { + _capWarned = true; + _logger.Warning($"[{Name}] emission cap reached — further power_state_change events " + + "suppressed this run (flapping power source or dying battery controller)"); + } + } + catch (Exception ex) + { + _logger.Debug($"[{Name}] debounced power tick failed: {ex.Message}"); + } + } + + private void EmitAll(IReadOnlyList emissions) + { + foreach (var emission in emissions) + { + try + { + var data = new Dictionary + { + { "transition", emission.Transition }, + { "onAcPower", emission.OnAcPower }, + { "batteryPercent", emission.BatteryPercent.HasValue ? (object)emission.BatteryPercent.Value : "unknown" }, + { "isCharging", emission.IsCharging }, + { "batteryLifeMinutes", emission.BatteryLifeMinutes.HasValue ? (object)emission.BatteryLifeMinutes.Value : "unknown" }, + }; + if (emission.ThresholdPercent.HasValue) + data["thresholdPercent"] = emission.ThresholdPercent.Value; + + _post.Emit(new EnrollmentEvent + { + SessionId = _sessionId, + TenantId = _tenantId, + EventType = Constants.EventTypes.PowerStateChange, + Severity = emission.Severity, + Source = Name, + Phase = EnrollmentPhase.Unknown, + Message = emission.Message, + ImmediateUpload = emission.ImmediateUpload, + Data = data, + }); + } + catch (Exception ex) + { + _logger.Warning($"[{Name}] failed to emit power_state_change: {ex.Message}"); + } + } + } + + public void Stop() + { + ManagementEventWatcher? watcher; + lock (_lock) + { + if (_disposed) return; + _disposed = true; + watcher = _watcher; + _watcher = null; + } + + if (watcher != null) + { + try { watcher.EventArrived -= OnPowerStatusChange; } catch { } + try { watcher.Stop(); } catch { } + try { watcher.Dispose(); } catch { } + } + + _debounceTimer?.Dispose(); + _debounceTimer = null; + } + + public void Dispose() => Stop(); + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/SignalActivityClassifier.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/SignalActivityClassifier.cs index 31c6d2eb..c6b97f36 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/SignalActivityClassifier.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/SignalActivityClassifier.cs @@ -56,6 +56,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration // real activity (rare, and worth keeping diagnostics running for). SharedConstants.EventTypes.ModernDeploymentLog, + // Environment observation, not device/enrollment progress: an idle device draining + // its battery must not keep the periodic collectors alive or push out stall + // detection — the exact scenario the power watcher exists to observe. + SharedConstants.EventTypes.PowerStateChange, + // Agent health / control / transport — not device/enrollment progress. SharedConstants.EventTypes.CollectorDegraded, SharedConstants.EventTypes.TelemetryUploadPoisoned, diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/PowerStateRuleTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/PowerStateRuleTests.cs new file mode 100644 index 00000000..aad08f8b --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/PowerStateRuleTests.cs @@ -0,0 +1,222 @@ +using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Shared.DataAccess; +using AutopilotMonitor.Shared.Models; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// Power watcher analyze rules — pins the firing behaviour of the built-ins that consume the +/// agent's live power_state_change events (PowerStateWatcherHost): +/// - ANALYZE-DEV-009 (high): battery crossed the 15% threshold while enrolling on battery. +/// - ANALYZE-DEV-010 (warning): the device switched from AC to battery mid-enrollment. +/// Both rules are deliberately single-required-condition: event_data conditions scan events +/// INDEPENDENTLY (no same-instance join), so a two-condition rule could false-positive across +/// two different power events. DEV-009 matches the agent-stamped thresholdPercent (only present +/// on threshold events, value pins the ladder level); DEV-010 matches transition=ac_to_battery. +/// Also pins that the rule's string "15" compares equal against the agent's numeric payload. +/// +public class PowerStateRuleTests +{ + private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"; + private const string SessionId = "11111111-2222-3333-4444-555555555555"; + + [Fact] + public async Task ANALYZE_DEV_009_fires_high_on_the_15_percent_threshold_event() + { + var rule = BuiltInAnalyzeRules.GetAll().First(r => r.RuleId == "ANALYZE-DEV-009"); + Assert.True(rule.Enabled); + Assert.Equal("high", rule.Severity); + Assert.False(rule.MarkSessionAsFailedDefault); // environment risk, not a KO verdict + + var events = new List + { + ThresholdCrossed(thresholdPercent: 15, batteryPercent: 13), + }; + + var outcome = await RunAsync(rule, events); + + var result = Assert.Single(outcome.Results); + Assert.Equal("ANALYZE-DEV-009", result.RuleId); + Assert.Equal("high", result.Severity); + + // The numeric payload (int 15) must satisfy the rule's string value "15"; field/value on + // the matched condition feed the {{thresholdPercent}} interpolation token. + var matched = AsDict(result.MatchedConditions["battery_threshold_15"]); + Assert.Equal("thresholdPercent", AsString(matched["field"])); + Assert.Equal("15", AsString(matched["value"])); + } + + [Theory] + [InlineData(50)] + [InlineData(30)] + public async Task ANALYZE_DEV_009_does_not_fire_on_higher_threshold_levels(int level) + { + var rule = BuiltInAnalyzeRules.GetAll().First(r => r.RuleId == "ANALYZE-DEV-009"); + + var events = new List + { + ThresholdCrossed(thresholdPercent: level, batteryPercent: level - 2), + }; + + var outcome = await RunAsync(rule, events); + Assert.Empty(outcome.Results); + } + + [Fact] + public async Task ANALYZE_DEV_009_does_not_fire_on_a_low_battery_transition_event() + { + // An ac_to_battery event at 13% carries batteryPercent but NO thresholdPercent — the + // agent emits the separate threshold event for that; the rule must not fire early here. + var rule = BuiltInAnalyzeRules.GetAll().First(r => r.RuleId == "ANALYZE-DEV-009"); + + var events = new List + { + Transition("ac_to_battery", onAcPower: false, batteryPercent: 13), + }; + + var outcome = await RunAsync(rule, events); + Assert.Empty(outcome.Results); + } + + [Fact] + public async Task ANALYZE_DEV_010_fires_warning_on_ac_to_battery() + { + var rule = BuiltInAnalyzeRules.GetAll().First(r => r.RuleId == "ANALYZE-DEV-010"); + Assert.True(rule.Enabled); + Assert.Equal("warning", rule.Severity); + Assert.False(rule.MarkSessionAsFailedDefault); + + var events = new List + { + Transition("ac_to_battery", onAcPower: false, batteryPercent: 63), + }; + + var outcome = await RunAsync(rule, events); + + var result = Assert.Single(outcome.Results); + Assert.Equal("ANALYZE-DEV-010", result.RuleId); + Assert.Equal("warning", result.Severity); + + var matched = AsDict(result.MatchedConditions["switched_to_battery"]); + Assert.Equal("transition", AsString(matched["field"])); + Assert.Equal("ac_to_battery", AsString(matched["value"])); + } + + [Theory] + [InlineData("battery_to_ac")] + [InlineData("threshold_crossed")] + public async Task ANALYZE_DEV_010_does_not_fire_on_other_transitions(string transition) + { + var rule = BuiltInAnalyzeRules.GetAll().First(r => r.RuleId == "ANALYZE-DEV-010"); + + var events = new List + { + Transition(transition, onAcPower: transition == "battery_to_ac", batteryPercent: 40), + }; + + var outcome = await RunAsync(rule, events); + Assert.Empty(outcome.Results); + } + + [Fact] + public async Task Neither_rule_fires_on_the_startup_power_state_check() + { + // The one-shot startup probe event (power_state_check) is a different event type and must + // not satisfy either rule, even when it reports a low battery on DC power. + var rules = BuiltInAnalyzeRules.GetAll() + .Where(r => r.RuleId is "ANALYZE-DEV-009" or "ANALYZE-DEV-010").ToList(); + Assert.Equal(2, rules.Count); + + var events = new List + { + new() + { + EventId = Guid.NewGuid().ToString(), + TenantId = TenantId, + SessionId = SessionId, + EventType = "power_state_check", + Timestamp = DateTime.UtcNow, + Sequence = 5, + Data = new Dictionary + { + ["onAcPower"] = false, + ["hasBattery"] = true, + ["batteryPercent"] = 12, + ["isCharging"] = false, + }, + }, + }; + + foreach (var rule in rules) + { + var outcome = await RunAsync(rule, events); + Assert.Empty(outcome.Results); + } + } + + // ===== Event builders — mirror the PowerStateWatcherHost emit shape ===== + + private static EnrollmentEvent ThresholdCrossed(int thresholdPercent, int batteryPercent) => new() + { + EventId = Guid.NewGuid().ToString(), + TenantId = TenantId, + SessionId = SessionId, + EventType = "power_state_change", + Timestamp = DateTime.UtcNow, + Sequence = 42, + Data = new Dictionary + { + ["transition"] = "threshold_crossed", + ["thresholdPercent"] = thresholdPercent, + ["onAcPower"] = false, + ["batteryPercent"] = batteryPercent, + ["isCharging"] = false, + ["batteryLifeMinutes"] = 25, + } + }; + + private static EnrollmentEvent Transition(string transition, bool onAcPower, int batteryPercent) => new() + { + EventId = Guid.NewGuid().ToString(), + TenantId = TenantId, + SessionId = SessionId, + EventType = "power_state_change", + Timestamp = DateTime.UtcNow, + Sequence = 43, + Data = new Dictionary + { + ["transition"] = transition, + ["onAcPower"] = onAcPower, + ["batteryPercent"] = batteryPercent, + ["isCharging"] = onAcPower, + ["batteryLifeMinutes"] = 120, + } + }; + + private static Dictionary AsDict(object o) + { + if (o is Dictionary d) return d; + throw new InvalidOperationException($"Expected Dictionary, got {o?.GetType().Name ?? "null"}"); + } + + private static string AsString(object o) => o?.ToString() ?? string.Empty; + + private static async Task RunAsync(AnalyzeRule rule, List events) + { + var ruleRepo = new Mock(); + ruleRepo.Setup(r => r.GetAnalyzeRulesAsync("global")).ReturnsAsync(new List { rule }); + ruleRepo.Setup(r => r.GetAnalyzeRulesAsync(TenantId)).ReturnsAsync(new List()); + ruleRepo.Setup(r => r.GetRuleStatesAsync(It.IsAny())).ReturnsAsync(new Dictionary()); + ruleRepo.Setup(r => r.GetRuleResultsAsync(It.IsAny(), It.IsAny())).ReturnsAsync(new List()); + + var sessionRepo = new Mock(); + sessionRepo.Setup(s => s.GetSessionEventsStrictAsync(TenantId, SessionId, It.IsAny())).ReturnsAsync(events); + + var ruleService = new AnalyzeRuleService(ruleRepo.Object, NullLogger.Instance); + var engine = new RuleEngine(ruleService, ruleRepo.Object, sessionRepo.Object, NullLogger.Instance); + + return await engine.AnalyzeSessionAsync(TenantId, SessionId); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAgentConfigFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAgentConfigFunction.cs index 55c952b1..0f361400 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAgentConfigFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAgentConfigFunction.cs @@ -274,7 +274,7 @@ namespace AutopilotMonitor.Functions.Functions.Config var response = req.CreateResponse(HttpStatusCode.OK); await response.WriteAsJsonAsync(new AgentConfigResponse { - ConfigVersion = 37, // EnableEspContinueAnywayObservation (Continue-Anyway observation mode) + ConfigVersion = 38, // Collectors.EnablePowerStateWatcher (live AC/battery watcher) UploadIntervalSeconds = Shared.Constants.DefaultUploadIntervalSeconds, SelfDestructOnComplete = tenantConfig.SelfDestructOnComplete ?? true, KeepLogFile = tenantConfig.KeepLogFile ?? false, diff --git a/src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.Classification.cs b/src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.Classification.cs index b1c77ea9..e050c000 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.Classification.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.Classification.cs @@ -378,6 +378,9 @@ namespace AutopilotMonitor.Functions.Services "stall_probe_result" => true, "session_stalled" => true, "modern_deployment_log" => true, + // Environment observation (battery drain / AC flap), not enrollment progress — + // must not count as "real activity" that heals a Stalled session. + "power_state_change" => true, _ => false }; diff --git a/src/McpServer/autopilot-monitor-mcp/src/resource-catalog.ts b/src/McpServer/autopilot-monitor-mcp/src/resource-catalog.ts index 43bfeb6a..d34d5d4f 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/resource-catalog.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/resource-catalog.ts @@ -110,6 +110,7 @@ export const EVENT_TYPES_CATALOG = { 'timezone_auto_set', 'ntp_time_check', 'power_state_check', + 'power_state_change', ], identity_events: [ 'aad_placeholder_user_detected', diff --git a/src/Shared/AutopilotMonitor.Shared/Constants.cs b/src/Shared/AutopilotMonitor.Shared/Constants.cs index b622bf36..3660294e 100644 --- a/src/Shared/AutopilotMonitor.Shared/Constants.cs +++ b/src/Shared/AutopilotMonitor.Shared/Constants.cs @@ -562,6 +562,7 @@ namespace AutopilotMonitor.Shared public const string TimezoneAutoSet = "timezone_auto_set"; public const string NtpTimeCheck = "ntp_time_check"; public const string PowerStateCheck = "power_state_check"; + public const string PowerStateChange = "power_state_change"; public const string AgentTrace = "agent_trace"; // Crash / process lifecycle diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Config/AgentConfigResponse.cs b/src/Shared/AutopilotMonitor.Shared/Models/Config/AgentConfigResponse.cs index e33e1527..a22a1798 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/Config/AgentConfigResponse.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/Config/AgentConfigResponse.cs @@ -355,6 +355,16 @@ namespace AutopilotMonitor.Shared.Models /// public int OfficeInstallSettleSeconds { get; set; } = 10; + /// + /// Enable the live AC/battery power-state watcher. Event-driven (no polling): a WMI + /// Win32_PowerManagementEvent (EventCode 10) push triggers a GetSystemPowerStatus + /// re-probe; emits power_state_change on AC↔battery transitions and downward battery + /// threshold crossings (50/30/15 %). Never armed on devices without a battery. + /// Cheap and high-value → on by default, kept as a kill-switch. + /// Default: true + /// + public bool EnablePowerStateWatcher { get; set; } = true; + // ----------------------------------------------------------------------- // Stall detection (Ebene 2 — StallProbeCollector) // ----------------------------------------------------------------------- diff --git a/src/Web/autopilot-monitor-web/app/gather-rules/eventTypes.ts b/src/Web/autopilot-monitor-web/app/gather-rules/eventTypes.ts index 1053738a..22b9908b 100644 --- a/src/Web/autopilot-monitor-web/app/gather-rules/eventTypes.ts +++ b/src/Web/autopilot-monitor-web/app/gather-rules/eventTypes.ts @@ -152,6 +152,8 @@ export const KNOWN_EVENT_TYPES: EventTypeEntry[] = [ description: "Certificate validation result." }, { value: "network_state_change", label: "network_state_change", category: "diagnostics", description: "Network state changed (online/offline/interface)." }, + { value: "power_state_change", label: "power_state_change", category: "diagnostics", + description: "Live AC/battery change during enrollment (WMI push, no polling): transition=ac_to_battery (Warning) / battery_to_ac (Info) / threshold_crossed with thresholdPercent 50/30/15 (each level latched once per session). Payload carries onAcPower, batteryPercent, isCharging, batteryLifeMinutes. Never emitted on devices without a battery." }, { value: "network_bandwidth_estimate", label: "network_bandwidth_estimate", category: "diagnostics", description: "Session-level internet-bandwidth estimate derived passively from the Delivery Optimization byte counters (no synthetic traffic). At most twice per session: an interim snapshot after DeviceSetup (snapshotTrigger=device_setup_end — survives account-phase starvation) and the authoritative final at enrollment end (collector_stop). p90/max Mbit/s for internet-path (HTTP + internet peers) and LAN sources (peers/Connected Cache) separately, plus bucket (<10 / 10-50 / 50-100 / 100-250 / 250+) and confidence." }, { value: "vulnerability_report", label: "vulnerability_report", category: "diagnostics", diff --git a/src/Web/autopilot-monitor-web/utils/shared-manifests.generated.ts b/src/Web/autopilot-monitor-web/utils/shared-manifests.generated.ts index dc605541..f883e33a 100644 --- a/src/Web/autopilot-monitor-web/utils/shared-manifests.generated.ts +++ b/src/Web/autopilot-monitor-web/utils/shared-manifests.generated.ts @@ -596,6 +596,7 @@ export const SHARED_MANIFEST = { "performance_collector_stopped", "performance_snapshot", "phase_transition", + "power_state_change", "power_state_check", "previous_crash_detected", "prior_run_died_with_state", diff --git a/src/Web/autopilot-monitor-web/utils/shared-manifests.json b/src/Web/autopilot-monitor-web/utils/shared-manifests.json index 94b8ad35..7be85b28 100644 --- a/src/Web/autopilot-monitor-web/utils/shared-manifests.json +++ b/src/Web/autopilot-monitor-web/utils/shared-manifests.json @@ -592,6 +592,7 @@ "performance_collector_stopped", "performance_snapshot", "phase_transition", + "power_state_change", "power_state_check", "previous_crash_detected", "prior_run_died_with_state",