feat(scheduler-gate): throttle background AI on battery / busy CPU (#1062)

This commit is contained in:
Steven Enamakel
2026-04-30 23:13:34 -07:00
committed by GitHub
parent 44988bdb80
commit 9dd878cbfb
14 changed files with 720 additions and 8 deletions
Generated
+67 -1
View File
@@ -1166,7 +1166,7 @@ dependencies = [
"jni 0.21.1",
"js-sys",
"libc",
"mach2",
"mach2 0.4.3",
"ndk",
"ndk-context",
"oboe",
@@ -3280,6 +3280,12 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]]
name = "lazycell"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55"
[[package]]
name = "leb128fmt"
version = "0.1.0"
@@ -3453,6 +3459,15 @@ dependencies = [
"libc",
]
[[package]]
name = "mach2"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a1b95cd5421ec55b445b5ae102f5ea0e768de1f82bd3001e11f426c269c3aea"
dependencies = [
"libc",
]
[[package]]
name = "macroific"
version = "2.0.0"
@@ -4609,6 +4624,7 @@ dependencies = [
"sha2 0.10.9",
"shellexpand",
"socketioxide",
"starship-battery",
"sysinfo",
"tar",
"tempfile",
@@ -5014,6 +5030,19 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
[[package]]
name = "plist"
version = "1.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07"
dependencies = [
"base64",
"indexmap",
"quick-xml",
"serde",
"time",
]
[[package]]
name = "png"
version = "0.18.1"
@@ -5327,6 +5356,15 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
[[package]]
name = "quick-xml"
version = "0.38.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c"
dependencies = [
"memchr",
]
[[package]]
name = "quinn"
version = "0.11.9"
@@ -6630,6 +6668,24 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "starship-battery"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd0efc2c44c92705be724265a0c758e3b7c120ea63817d2d684bab86fbeced9a"
dependencies = [
"cfg-if",
"core-foundation 0.10.1",
"lazycell",
"libc",
"mach2 0.5.0",
"nix",
"num-traits",
"plist",
"uom",
"windows-sys 0.61.2",
]
[[package]]
name = "stop-token"
version = "0.7.0"
@@ -7479,6 +7535,16 @@ version = "0.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae"
[[package]]
name = "uom"
version = "0.37.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd5cfe7d84f6774726717f358a37f5bca8fca273bed4de40604ad129d1107b49"
dependencies = [
"num-traits",
"typenum",
]
[[package]]
name = "ureq"
version = "3.3.0"
+5
View File
@@ -117,6 +117,11 @@ enigo = "0.3"
arboard = "3"
rdev = "0.5"
fs2 = "0.4"
# Cross-platform battery probe for the scheduler gate. Maintained fork of
# the abandoned `battery` crate; same `use battery::*;` API surface. Used
# only by `openhuman::scheduler_gate::signals` to decide when to throttle
# background LLM work on laptops.
starship-battery = "0.10"
matrix-sdk = { version = "0.16", optional = true, default-features = false, features = ["e2e-encryption", "rustls-tls", "markdown"] }
fantoccini = { version = "0.22.0", optional = true, default-features = false, features = ["rustls-tls"] }
+5
View File
@@ -858,6 +858,11 @@ fn register_domain_subscribers(
}
crate::openhuman::composio::register_composio_trigger_subscriber();
crate::openhuman::composio::start_periodic_sync();
// Initialise the scheduler gate before any background AI workers
// start so they observe a real policy on their first iteration
// (otherwise they fall back to `Policy::Normal` and miss the
// initial throttle decision on battery-powered hosts).
crate::openhuman::scheduler_gate::init_global(&config);
crate::openhuman::memory::tree::jobs::start(config.clone());
// Restart requests go through a subscriber so every trigger path shares
+10 -3
View File
@@ -8,7 +8,7 @@
//!
//! Design notes:
//!
//! * One global tick (60s) drives every provider — we don't spawn a
//! * One global tick (5min) drives every provider — we don't spawn a
//! task per connection, because the number of connections per user
//! is small and a single tick keeps the bookkeeping trivial.
//! * Per-connection state (last sync timestamp) lives in a
@@ -36,7 +36,14 @@ use super::providers::{get_provider, ProviderContext, SyncReason};
/// How often the scheduler wakes up to look for due syncs. Independent
/// from per-provider `sync_interval_secs` — this just bounds how long
/// past a provider's interval we might fire.
const TICK_SECONDS: u64 = 60;
///
/// 5 min trades a little staleness for noticeably less foreground load:
/// each tick triggers an HTTP fetch + DB write per due connection, and
/// for users with several connected providers the old 60s cadence kept
/// the laptop visibly busy. Per-provider `sync_interval_secs` still
/// caps the *minimum* delay between actual syncs — this only loosens
/// the upper bound.
const TICK_SECONDS: u64 = 300;
/// Process-wide guard so the scheduler is only started once even
/// when both `start_channels` and `bootstrap_skill_runtime` call into
@@ -228,7 +235,7 @@ mod tests {
fn tick_seconds_is_sane_default() {
// Sanity check: don't accidentally ship a 1-second tick.
assert!(TICK_SECONDS >= 30);
assert!(TICK_SECONDS <= 300);
assert!(TICK_SECONDS <= 600);
}
#[test]
+5 -4
View File
@@ -32,10 +32,11 @@ pub use schema::{
LocalAiConfig, MatrixConfig, MemoryConfig, ModelRouteConfig, MultimodalConfig,
ObservabilityConfig, ProxyConfig, ProxyScope, ReflectionSource, ReliabilityConfig,
ResourceLimitsConfig, RuntimeConfig, SandboxBackend, SandboxConfig, SchedulerConfig,
ScreenIntelligenceConfig, SecretsConfig, SecurityConfig, SlackConfig, StorageConfig,
StorageProviderConfig, StorageProviderSection, StreamMode, TelegramConfig, UpdateConfig,
VoiceActivationMode, VoiceServerConfig, WebSearchConfig, WebhookConfig, DEFAULT_MODEL,
MODEL_AGENTIC_V1, MODEL_CODING_V1, MODEL_REASONING_V1,
SchedulerGateConfig, SchedulerGateMode, ScreenIntelligenceConfig, SecretsConfig,
SecurityConfig, SlackConfig, StorageConfig, StorageProviderConfig, StorageProviderSection,
StreamMode, TelegramConfig, UpdateConfig, VoiceActivationMode, VoiceServerConfig,
WebSearchConfig, WebhookConfig, DEFAULT_MODEL, MODEL_AGENTIC_V1, MODEL_CODING_V1,
MODEL_REASONING_V1,
};
pub use schema::{
clear_active_user, default_root_openhuman_dir, pre_login_user_dir, read_active_user_id,
+2
View File
@@ -24,6 +24,7 @@ mod observability;
mod proxy;
mod routes;
mod runtime;
mod scheduler_gate;
mod storage_memory;
mod tools;
mod update;
@@ -53,6 +54,7 @@ pub use proxy::{
};
pub use routes::{EmbeddingRouteConfig, ModelRouteConfig};
pub use runtime::{DockerRuntimeConfig, ReliabilityConfig, RuntimeConfig, SchedulerConfig};
pub use scheduler_gate::{SchedulerGateConfig, SchedulerGateMode};
pub use storage_memory::{
MemoryConfig, MemoryTreeConfig, StorageConfig, StorageProviderConfig, StorageProviderSection,
};
@@ -0,0 +1,86 @@
//! Scheduler-gate configuration — controls when background AI work runs.
//!
//! Consumed by [`crate::openhuman::scheduler_gate`].
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum SchedulerGateMode {
/// Decide based on power + CPU + deployment-mode signals.
Auto,
/// Always run background AI flat-out (server / power-user setting).
AlwaysOn,
/// Never run background AI. User can still trigger work explicitly.
Off,
}
impl SchedulerGateMode {
pub fn as_str(self) -> &'static str {
match self {
Self::Auto => "auto",
Self::AlwaysOn => "always_on",
Self::Off => "off",
}
}
}
impl Default for SchedulerGateMode {
fn default() -> Self {
Self::Auto
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct SchedulerGateConfig {
/// Top-level mode — `auto` (default), `always_on`, or `off`.
#[serde(default)]
pub mode: SchedulerGateMode,
/// Battery charge floor in `auto` mode, 0.0..=1.0. Below this and not on
/// AC, the gate throttles. Default: 0.80.
#[serde(default = "default_battery_floor")]
pub battery_floor: f32,
/// CPU busy threshold (recent global usage, 0..100). Above this, the gate
/// throttles even when plugged in. Default: 70.0 (i.e. <30% headroom).
#[serde(default = "default_cpu_busy_threshold")]
pub cpu_busy_threshold_pct: f32,
/// In `Throttled` mode, sleep this many ms before each LLM-bound job to
/// serialise workers and let the host catch up. Default: 30_000 (30s).
#[serde(default = "default_throttled_backoff_ms")]
pub throttled_backoff_ms: u64,
/// In `Paused` mode, re-check the policy every this many ms so workers
/// resume promptly when the user toggles the gate back on. Default:
/// 60_000 (60s).
#[serde(default = "default_paused_poll_ms")]
pub paused_poll_ms: u64,
}
fn default_battery_floor() -> f32 {
0.80
}
fn default_cpu_busy_threshold() -> f32 {
70.0
}
fn default_throttled_backoff_ms() -> u64 {
30_000
}
fn default_paused_poll_ms() -> u64 {
60_000
}
impl Default for SchedulerGateConfig {
fn default() -> Self {
Self {
mode: SchedulerGateMode::default(),
battery_floor: default_battery_floor(),
cpu_busy_threshold_pct: default_cpu_busy_threshold(),
throttled_backoff_ms: default_throttled_backoff_ms(),
paused_poll_ms: default_paused_poll_ms(),
}
}
}
+8
View File
@@ -54,6 +54,13 @@ pub struct Config {
#[serde(default)]
pub scheduler: SchedulerConfig,
/// Background-AI scheduler gate — throttles memory-tree digests,
/// embeddings, and other LLM-bound background work based on power
/// state, CPU pressure, and deployment mode. See
/// [`crate::openhuman::scheduler_gate`].
#[serde(default)]
pub scheduler_gate: SchedulerGateConfig,
#[serde(default)]
pub agent: AgentConfig,
@@ -251,6 +258,7 @@ impl Default for Config {
autocomplete: AutocompleteConfig::default(),
reliability: ReliabilityConfig::default(),
scheduler: SchedulerConfig::default(),
scheduler_gate: SchedulerGateConfig::default(),
agent: AgentConfig::default(),
context: ContextConfig::default(),
model_routes: Vec::new(),
+13
View File
@@ -73,6 +73,19 @@ pub async fn run_once(config: &Config) -> Result<bool> {
}
async fn run_once_with_semaphore(config: &Config, llm_slots: Arc<Semaphore>) -> Result<bool> {
// Cooperative throttle BEFORE `claim_next()`. Holding the DB claim
// across an awaited `wait_for_capacity()` would let `Paused` mode
// sit on the row past `DEFAULT_LOCK_DURATION_MS`, after which
// `recover_stale_locks()` would requeue it for another worker to
// pick up — duplicating side effects. Throttling here means
// non-LLM jobs (AppendBuffer/FlushStale) also experience the same
// gate delay, but that's fine: in Throttled mode the host is
// already overloaded and a 30s breather between any DB-write batch
// is welcome; in Paused mode the user has explicitly asked us to
// stand down. Returns immediately in Aggressive/Normal so plugged-in
// desktops with headroom pay zero cost.
crate::openhuman::scheduler_gate::wait_for_capacity().await;
let Some(job) = claim_next(config, DEFAULT_LOCK_DURATION_MS)? else {
return Ok(false);
};
+1
View File
@@ -47,6 +47,7 @@ pub mod providers;
pub mod redirect_links;
pub mod referral;
pub mod routing;
pub mod scheduler_gate;
pub mod screen_intelligence;
pub mod security;
pub mod service;
+144
View File
@@ -0,0 +1,144 @@
//! Process-wide singleton: cached policy + cooperative throttling.
//!
//! One sampler task refreshes [`Signals`] every 30s and recomputes the
//! [`Policy`]. Workers call [`current_policy`] for cheap reads or
//! [`wait_for_capacity`] to cooperatively block until the host is ready.
use std::sync::{Arc, OnceLock};
use std::time::Duration;
use parking_lot::RwLock;
use crate::openhuman::config::{Config, SchedulerGateConfig};
use crate::openhuman::scheduler_gate::policy::{decide, Policy};
use crate::openhuman::scheduler_gate::signals::Signals;
struct State {
cfg: SchedulerGateConfig,
signals: Signals,
policy: Policy,
}
static STATE: OnceLock<Arc<RwLock<State>>> = OnceLock::new();
static STARTED: std::sync::Once = std::sync::Once::new();
const SAMPLE_INTERVAL: Duration = Duration::from_secs(30);
/// Initialise the gate and spawn the background sampler.
///
/// Idempotent — repeat calls during bootstrap are no-ops. Subsequent config
/// reloads should call [`update_config`] instead.
pub fn init_global(config: &Config) {
let cfg = config.scheduler_gate.clone();
STARTED.call_once(|| {
let signals = Signals::sample();
let policy = decide(&signals, &cfg);
log::info!(
"[scheduler_gate] startup policy={} mode={} on_ac={} charge={:?} cpu={:.1}% server={}",
policy.as_str(),
cfg.mode.as_str(),
signals.on_ac_power,
signals.battery_charge,
signals.cpu_usage_pct,
signals.server_mode,
);
let state = Arc::new(RwLock::new(State {
cfg,
signals,
policy,
}));
let _ = STATE.set(state.clone());
tokio::spawn(async move {
loop {
tokio::time::sleep(SAMPLE_INTERVAL).await;
// Sampling does a brief blocking sleep + sysinfo refresh —
// push it off the async runtime.
let signals = match tokio::task::spawn_blocking(Signals::sample).await {
Ok(s) => s,
Err(err) => {
log::warn!("[scheduler_gate] sampler join error: {err:#}");
continue;
}
};
let mut guard = state.write();
let next = decide(&signals, &guard.cfg);
if next != guard.policy {
log::info!(
"[scheduler_gate] policy {} -> {} (on_ac={} charge={:?} cpu={:.1}% server={})",
guard.policy.as_str(),
next.as_str(),
signals.on_ac_power,
signals.battery_charge,
signals.cpu_usage_pct,
signals.server_mode,
);
}
guard.signals = signals;
guard.policy = next;
}
});
});
}
/// Update the gate's view of user config (e.g. after a settings change).
pub fn update_config(cfg: SchedulerGateConfig) {
if let Some(state) = STATE.get() {
let mut guard = state.write();
guard.cfg = cfg;
guard.policy = decide(&guard.signals, &guard.cfg);
}
}
/// Current policy. Defaults to [`Policy::Normal`] before [`init_global`] runs
/// (e.g. in unit tests) so callers don't deadlock waiting on a sampler that
/// will never start.
pub fn current_policy() -> Policy {
STATE
.get()
.map(|s| s.read().policy)
.unwrap_or(Policy::Normal)
}
/// Most recent sampled signals, or a neutral default if the sampler hasn't run.
pub fn current_signals() -> Signals {
STATE.get().map(|s| s.read().signals).unwrap_or(Signals {
on_ac_power: true,
battery_charge: None,
cpu_usage_pct: 0.0,
server_mode: false,
})
}
/// Cooperatively block a worker until the host is ready for LLM-bound work.
///
/// * **Aggressive / Normal** — returns immediately.
/// * **Throttled** — sleeps `throttled_backoff_ms` so concurrent workers
/// serialise themselves and the host catches its breath between jobs.
/// * **Paused** — polls every `paused_poll_ms` until the policy changes.
///
/// Designed so existing semaphore-bounded worker pools can keep their pool
/// size and just gain a per-job throttle in front of the existing
/// `semaphore.acquire()` call.
pub async fn wait_for_capacity() {
loop {
let (policy, throttled_ms, paused_ms) = match STATE.get() {
Some(state) => {
let g = state.read();
(g.policy, g.cfg.throttled_backoff_ms, g.cfg.paused_poll_ms)
}
None => return,
};
match policy {
Policy::Aggressive | Policy::Normal => return,
Policy::Throttled => {
tokio::time::sleep(Duration::from_millis(throttled_ms)).await;
return;
}
Policy::Paused => {
tokio::time::sleep(Duration::from_millis(paused_ms)).await;
// re-evaluate; user may have toggled the gate back on.
}
}
}
}
+30
View File
@@ -0,0 +1,30 @@
//! Scheduler gate — gates background AI work on host conditions.
//!
//! Background AI tasks (memory-tree digests, embeddings, summarisation) used
//! to run flat-out and made the host visibly lag, especially on battery.
//! This module exposes a single decision point — [`current_policy`] — that
//! background workers consult before spending CPU/GPU on LLM-bound work.
//!
//! Signals (refreshed every 30s in a background sampler):
//! * power state — on AC, or battery >= 80%
//! * CPU usage — recent global usage; <70% means "idle enough"
//! * deployment mode — server/container hosts run flat-out
//!
//! Resulting [`Policy`]:
//! * [`Policy::Aggressive`] — server-mode; bypass throttles entirely
//! * [`Policy::Normal`] — desktop with headroom; run as scheduled
//! * [`Policy::Throttled`] — busy or on battery; serialise + slow down
//! * [`Policy::Paused`] — user opted out; defer indefinitely
//!
//! Cooperative throttling: callers `await gate::wait_for_capacity()` before
//! each unit of LLM-bound work. The future resolves immediately in
//! Aggressive/Normal, sleeps in Throttled, and re-polls in Paused so the
//! caller resumes the moment the user toggles the gate back on.
pub mod gate;
pub mod policy;
pub mod signals;
pub use gate::{current_policy, current_signals, init_global, wait_for_capacity};
pub use policy::Policy;
pub use signals::Signals;
+188
View File
@@ -0,0 +1,188 @@
//! Decision logic — turn raw [`Signals`] + user config into a [`Policy`].
use crate::openhuman::config::SchedulerGateConfig;
use crate::openhuman::scheduler_gate::signals::Signals;
/// Background-AI scheduling tier. See module docs in `mod.rs` for semantics.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Policy {
Aggressive,
Normal,
Throttled,
Paused,
}
impl Policy {
pub fn as_str(self) -> &'static str {
match self {
Self::Aggressive => "aggressive",
Self::Normal => "normal",
Self::Throttled => "throttled",
Self::Paused => "paused",
}
}
}
/// Compute the current [`Policy`] from sampled signals + user config.
///
/// Order of evaluation matters — explicit user overrides win first, then
/// deployment mode, then dynamic host signals.
pub fn decide(signals: &Signals, cfg: &SchedulerGateConfig) -> Policy {
use crate::openhuman::config::SchedulerGateMode;
match cfg.mode {
SchedulerGateMode::Off => return Policy::Paused,
SchedulerGateMode::AlwaysOn => return Policy::Aggressive,
SchedulerGateMode::Auto => {}
}
if signals.server_mode {
return Policy::Aggressive;
}
// Clamp config-supplied thresholds so a malformed config.toml (e.g.
// `battery_floor = 1.5` or a negative cpu threshold) can't silently
// disable / force throttling — the field is `f32` and serde won't
// reject out-of-domain values for us.
let battery_floor = cfg.battery_floor.clamp(0.0, 1.0);
let cpu_threshold = cfg.cpu_busy_threshold_pct.clamp(0.0, 100.0);
let battery_ok = signals.on_ac_power
|| signals
.battery_charge
.map(|c| c >= battery_floor)
.unwrap_or(true); // no battery present == treat as plugged in
let cpu_ok = signals.cpu_usage_pct <= cpu_threshold;
if battery_ok && cpu_ok {
Policy::Normal
} else {
Policy::Throttled
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::openhuman::config::{SchedulerGateConfig, SchedulerGateMode};
fn cfg(mode: SchedulerGateMode) -> SchedulerGateConfig {
SchedulerGateConfig {
mode,
battery_floor: 0.8,
cpu_busy_threshold_pct: 70.0,
throttled_backoff_ms: 30_000,
paused_poll_ms: 60_000,
}
}
fn signals(on_ac: bool, charge: Option<f32>, cpu: f32, server: bool) -> Signals {
Signals {
on_ac_power: on_ac,
battery_charge: charge,
cpu_usage_pct: cpu,
server_mode: server,
}
}
#[test]
fn off_mode_pauses() {
let p = decide(
&signals(true, None, 5.0, true),
&cfg(SchedulerGateMode::Off),
);
assert_eq!(p, Policy::Paused);
}
#[test]
fn always_on_overrides_signals() {
// discharging laptop at 10% with 99% CPU — still Aggressive.
let p = decide(
&signals(false, Some(0.10), 99.0, false),
&cfg(SchedulerGateMode::AlwaysOn),
);
assert_eq!(p, Policy::Aggressive);
}
#[test]
fn server_mode_is_aggressive() {
let p = decide(
&signals(false, None, 50.0, true),
&cfg(SchedulerGateMode::Auto),
);
assert_eq!(p, Policy::Aggressive);
}
#[test]
fn plugged_in_idle_is_normal() {
let p = decide(
&signals(true, Some(0.45), 20.0, false),
&cfg(SchedulerGateMode::Auto),
);
assert_eq!(p, Policy::Normal);
}
#[test]
fn battery_above_floor_is_normal() {
let p = decide(
&signals(false, Some(0.85), 20.0, false),
&cfg(SchedulerGateMode::Auto),
);
assert_eq!(p, Policy::Normal);
}
#[test]
fn battery_below_floor_throttles() {
let p = decide(
&signals(false, Some(0.30), 20.0, false),
&cfg(SchedulerGateMode::Auto),
);
assert_eq!(p, Policy::Throttled);
}
#[test]
fn busy_cpu_throttles_even_when_plugged_in() {
let p = decide(
&signals(true, Some(0.95), 90.0, false),
&cfg(SchedulerGateMode::Auto),
);
assert_eq!(p, Policy::Throttled);
}
#[test]
fn out_of_range_battery_floor_is_clamped() {
// 1.5 clamped to 1.0 — with charge < 1.0 on battery, must throttle.
let mut c = cfg(SchedulerGateMode::Auto);
c.battery_floor = 1.5;
let p = decide(&signals(false, Some(0.99), 10.0, false), &c);
assert_eq!(p, Policy::Throttled);
// -1.0 clamped to 0.0 — any non-zero charge passes the floor.
c.battery_floor = -1.0;
let p = decide(&signals(false, Some(0.05), 10.0, false), &c);
assert_eq!(p, Policy::Normal);
}
#[test]
fn out_of_range_cpu_threshold_is_clamped() {
// 200.0 clamped to 100.0 — nothing above it, never throttles on CPU.
let mut c = cfg(SchedulerGateMode::Auto);
c.cpu_busy_threshold_pct = 200.0;
let p = decide(&signals(true, None, 99.0, false), &c);
assert_eq!(p, Policy::Normal);
// -10.0 clamped to 0.0 — any positive CPU usage throttles.
c.cpu_busy_threshold_pct = -10.0;
let p = decide(&signals(true, None, 5.0, false), &c);
assert_eq!(p, Policy::Throttled);
}
#[test]
fn no_battery_treated_as_plugged_in() {
// Desktop / server with no battery sensor — treat as AC.
let p = decide(
&signals(false, None, 20.0, false),
&cfg(SchedulerGateMode::Auto),
);
assert_eq!(p, Policy::Normal);
}
}
+156
View File
@@ -0,0 +1,156 @@
//! Host signals: power state, CPU pressure, deployment mode.
//!
//! Sampled on a 30s cadence by [`crate::openhuman::scheduler_gate::gate`]; this
//! file just captures one snapshot at a time.
use std::path::Path;
use std::sync::Mutex;
use std::time::Duration;
use once_cell::sync::Lazy;
use sysinfo::System;
#[derive(Debug, Clone, Copy)]
pub struct Signals {
pub on_ac_power: bool,
/// 0.0..=1.0, or `None` when no battery sensor is present (most servers).
pub battery_charge: Option<f32>,
/// Recent global CPU usage, 0..100.
pub cpu_usage_pct: f32,
pub server_mode: bool,
}
impl Signals {
/// Sample once. Cheap (~ms-scale) — safe to call from a 30s background task.
pub fn sample() -> Self {
let (on_ac, charge) = sample_power();
let cpu_usage_pct = sample_cpu();
let server_mode = detect_server_mode(charge.is_none());
Self {
on_ac_power: on_ac,
battery_charge: charge,
cpu_usage_pct,
server_mode,
}
}
}
// ---- power ---------------------------------------------------------------
fn sample_power() -> (bool, Option<f32>) {
// Env overrides win — useful for CI, container hosts that misreport,
// and manual debugging of the throttle path on a desktop. Only
// explicit truthy/falsy tokens count: garbage values yield None so
// the real probe still gets to answer (vs. silently coercing to
// "on battery" and triggering throttling on every misconfigured host).
let env_on_ac = std::env::var("OPENHUMAN_ON_AC_POWER").ok().and_then(|v| {
match v.to_ascii_lowercase().as_str() {
"1" | "true" | "yes" => Some(true),
"0" | "false" | "no" => Some(false),
_ => None,
}
});
let env_charge = std::env::var("OPENHUMAN_BATTERY_CHARGE")
.ok()
.and_then(|v| v.parse::<f32>().ok())
.map(|v| v.clamp(0.0, 1.0));
if let (Some(ac), Some(c)) = (env_on_ac, env_charge) {
return (ac, Some(c));
}
match probe_battery() {
Ok(probe) => (
env_on_ac.unwrap_or(probe.on_ac),
env_charge.or(probe.charge),
),
Err(err) => {
// Probe failure on Linux often just means no /sys/class/power_supply
// entries (server, container) — treat as "plugged in, no battery"
// which yields Normal/Aggressive, not Throttled. Log once at debug
// because this fires every 30s on the sampler tick.
log::debug!("[scheduler_gate] battery probe failed: {err:#}");
(env_on_ac.unwrap_or(true), env_charge)
}
}
}
struct BatteryProbe {
on_ac: bool,
charge: Option<f32>,
}
fn probe_battery() -> Result<BatteryProbe, starship_battery::Error> {
let manager = starship_battery::Manager::new()?;
let mut any = false;
let mut on_ac = true; // if all batteries report Charging/Full, we're on AC.
let mut total: f32 = 0.0;
let mut count: f32 = 0.0;
for maybe in manager.batteries()? {
let battery = maybe?;
any = true;
// Discharging is the only state that conclusively means "on battery".
// Unknown / Empty / Full / Charging all imply the AC adapter is
// present (or at minimum that the OS isn't draining the pack).
if matches!(battery.state(), starship_battery::State::Discharging) {
on_ac = false;
}
total += battery.state_of_charge().value;
count += 1.0;
}
let charge = if any && count > 0.0 {
Some((total / count).clamp(0.0, 1.0))
} else {
None
};
Ok(BatteryProbe { on_ac, charge })
}
// ---- cpu -----------------------------------------------------------------
static CPU_SYS: Lazy<Mutex<System>> = Lazy::new(|| Mutex::new(System::new()));
fn sample_cpu() -> f32 {
// Two refreshes spaced ~MINIMUM_CPU_UPDATE_INTERVAL apart give sysinfo
// a real delta to compute usage from. The interval is small enough to
// run on the 30s sampler tick without noticeable cost.
let mut sys = match CPU_SYS.lock() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
sys.refresh_cpu_usage();
std::thread::sleep(Duration::from_millis(
sysinfo::MINIMUM_CPU_UPDATE_INTERVAL.as_millis() as u64 + 50,
));
sys.refresh_cpu_usage();
sys.global_cpu_usage()
}
// ---- deployment mode -----------------------------------------------------
fn detect_server_mode(no_battery: bool) -> bool {
if let Ok(v) = std::env::var("OPENHUMAN_DEPLOYMENT") {
if v.eq_ignore_ascii_case("server") {
return true;
}
if matches!(v.to_ascii_lowercase().as_str(), "desktop" | "laptop") {
return false;
}
}
if std::env::var("KUBERNETES_SERVICE_HOST").is_ok() {
return true;
}
if Path::new("/.dockerenv").exists() {
return true;
}
// Heuristic of last resort: a Linux box with no battery and no display
// server set is almost certainly a server. We *don't* infer server-mode
// from "no battery" alone — desktops have no battery either.
if cfg!(target_os = "linux")
&& no_battery
&& std::env::var("DISPLAY").is_err()
&& std::env::var("WAYLAND_DISPLAY").is_err()
{
return true;
}
false
}